use anyhow::{bail, Context as _, Result};
use super::runtime::OauthRuntime;
use super::{client_auth, discovery, dpop, flow, keys, request, store, token};
const MAX_PENDING_SECS: i64 = 600;
pub struct StartedLogin {
pub authorize_url: String,
pub binding_token: String,
}
pub async fn start(
runtime: &OauthRuntime,
http: &reqwest::Client,
pool: &sqlx::SqlitePool,
subject: &str,
now: i64,
) -> Result<StartedLogin> {
start_with(
runtime,
pool,
subject,
now,
|subject| async move {
super::resolve::resolve(&runtime.resolver, http, &subject, &runtime.plc_directory).await
},
|pds_url, auth_method, expected_issuer| async move {
discovery::discover(http, &pds_url, auth_method, expected_issuer.as_deref()).await
},
|req: ParPost| async move {
post_form(http, pool, &req.url, &req.key, &req.params, req.retry).await
},
)
.await
}
struct ParPost {
url: String,
params: Vec<(&'static str, String)>,
key: std::sync::Arc<keys::SigningKey>,
retry: request::Retry,
}
#[allow(clippy::too_many_arguments)]
async fn start_with<R, RFut, D, DFut, P, PFut>(
runtime: &OauthRuntime,
pool: &sqlx::SqlitePool,
subject: &str,
now: i64,
resolve: R,
discover: D,
push: P,
) -> Result<StartedLogin>
where
R: FnOnce(String) -> RFut,
RFut: std::future::Future<Output = Result<super::resolve::ResolvedAccount>>,
D: FnOnce(String, &'static str, Option<String>) -> DFut,
DFut: std::future::Future<Output = Result<discovery::AuthorizationServer>>,
P: FnOnce(ParPost) -> PFut,
PFut: std::future::Future<Output = Result<request::PostOutcome>>,
{
let account = resolve(subject.to_string())
.await
.with_context(|| format!("resolving {subject:?}"))?;
let server = discover(account.pds_url.clone(), runtime.auth_method.as_str(), None)
.await
.with_context(|| {
format!(
"discovering the authorization server for {}",
account.pds_url
)
})?;
let session_key = keys::SigningKey::generate("session");
let verifier = flow::new_pkce_verifier();
let state = flow::new_state();
let binding_token = flow::new_binding_token();
let mut params = flow::par_params(&flow::ParRequest {
redirect_uri: &super::metadata::redirect_uri(&runtime.client),
scope: runtime.client.scope_str(),
state: &state,
code_challenge: &flow::pkce_challenge(&verifier),
login_hint: Some(subject),
});
let assertion = client_assertion(runtime, runtime.auth_method, &server.issuer, now)?;
params.extend(client_auth::credential_params(
runtime.auth_method,
&runtime.client_id,
assertion.as_deref(),
)?);
let session_key = std::sync::Arc::new(session_key);
let outcome = push(ParPost {
url: server.par_endpoint.clone(),
params,
key: std::sync::Arc::clone(&session_key),
retry: request::Retry::Allowed,
})
.await?;
let par = accept_par_response(&outcome)?;
store::put_pending(
pool,
&runtime.codec,
&store::PendingAuth {
state: state.clone(),
browser_binding_hash: flow::binding_hash(&binding_token),
pkce_verifier: verifier,
dpop_key_jwk: session_key.to_jwk_json()?,
issuer: server.issuer.clone(),
pds_url: account.pds_url.clone(),
did: account.did.clone(),
auth_method: runtime.auth_method.as_str().to_string(),
auth_kid: runtime.client_key.as_ref().map(|k| k.kid().to_string()),
redirect_uri: super::metadata::redirect_uri(&runtime.client),
requested_scope: runtime.client.scope_str().to_string(),
request_uri: par.request_uri.clone(),
app_return_to: None,
expires_at: pending_expiry(now, par.expires_in),
},
)
.await?;
Ok(StartedLogin {
authorize_url: flow::authorize_url(
&server.authorization_endpoint,
&runtime.client_id,
&par.request_uri,
)?,
binding_token,
})
}
#[derive(Debug)]
pub struct CompletedLogin {
pub did: String,
pub handle: Option<String>,
}
pub async fn complete(
runtime: &OauthRuntime,
http: &reqwest::Client,
pool: &sqlx::SqlitePool,
params: &flow::CallbackParams,
presented_cookie: Option<&str>,
now: i64,
) -> Result<CompletedLogin> {
complete_with(
runtime,
pool,
params,
presented_cookie,
now,
|req: Discovery| async move {
discovery::discover(
http,
&req.pds_url,
&req.auth_method,
req.expected_issuer.as_deref(),
)
.await
},
|req: TokenPost| async move {
post_form(http, pool, &req.url, &req.key, &req.params, req.retry).await
},
|did: String| async move {
super::resolve::resolve(&runtime.resolver, http, &did, &runtime.plc_directory).await
},
)
.await
}
struct Discovery {
pds_url: String,
auth_method: String,
expected_issuer: Option<String>,
}
struct TokenPost {
url: String,
params: Vec<(&'static str, String)>,
key: std::sync::Arc<keys::SigningKey>,
retry: request::Retry,
}
#[allow(clippy::too_many_arguments)]
async fn complete_with<D, DFut, P, PFut, R, RFut>(
runtime: &OauthRuntime,
pool: &sqlx::SqlitePool,
params: &flow::CallbackParams,
presented_cookie: Option<&str>,
now: i64,
discover: D,
post: P,
resolve_handle: R,
) -> Result<CompletedLogin>
where
D: Fn(Discovery) -> DFut,
DFut: std::future::Future<Output = Result<discovery::AuthorizationServer>>,
P: Fn(TokenPost) -> PFut,
PFut: std::future::Future<Output = Result<request::PostOutcome>>,
R: Fn(String) -> RFut,
RFut: std::future::Future<Output = Result<super::resolve::ResolvedAccount>>,
{
let (pending, code) =
flow::complete_callback(pool, &runtime.codec, params, presented_cookie, now).await?;
let key = std::sync::Arc::new(
keys::SigningKey::from_jwk_json(&pending.dpop_key_jwk, "session")
.context("unsealing the login's DPoP key")?,
);
let auth_method: client_auth::AuthMethod = pending
.auth_method
.parse()
.context("the pending login stored an unknown auth method")?;
let current_redirect = super::metadata::redirect_uri(&runtime.client);
if current_redirect != pending.redirect_uri {
bail!(
"this login was started under a different public URL (redirect {:?}, now {:?}); \
the client identity changed mid-flight and the exchange would be rejected",
pending.redirect_uri,
current_redirect
);
}
let server = discover(Discovery {
pds_url: pending.pds_url.clone(),
auth_method: auth_method.as_str().to_string(),
expected_issuer: Some(pending.issuer.clone()),
})
.await?;
let token_params = token_exchange_params(runtime, &pending, &code, auth_method, now)?;
let outcome = post(TokenPost {
url: server.token_endpoint.clone(),
params: token_params,
key: std::sync::Arc::clone(&key),
retry: request::Retry::Allowed,
})
.await?;
let did = accept_token_response(pool, &runtime.codec, &pending, &outcome, now).await?;
let handle = match resolve_handle(did.clone()).await {
Ok(account) => account.handle,
Err(err) => {
tracing::warn!(%err, did = %did, "could not resolve a handle for the new session");
None
}
};
Ok(CompletedLogin { did, handle })
}
fn pending_expiry(now: i64, par_expires_in: i64) -> i64 {
now + par_expires_in.min(MAX_PENDING_SECS)
}
fn accept_par_response(outcome: &request::PostOutcome) -> Result<flow::ParResponse> {
if !outcome.is_success() {
bail!(
"the pushed authorization request failed with status {}",
outcome.status
);
}
flow::parse_par_response(&outcome.json()?)
}
fn token_exchange_params(
runtime: &OauthRuntime,
pending: &store::PendingAuth,
code: &str,
auth_method: client_auth::AuthMethod,
now: i64,
) -> Result<Vec<(&'static str, String)>> {
let mut params =
token::token_request_params(code, &pending.redirect_uri, &pending.pkce_verifier);
let assertion = client_assertion(runtime, auth_method, &pending.issuer, now)?;
params.extend(client_auth::credential_params(
auth_method,
&runtime.client_id,
assertion.as_deref(),
)?);
Ok(params)
}
async fn accept_token_response(
pool: &sqlx::SqlitePool,
codec: &super::crypto::Codec,
pending: &store::PendingAuth,
outcome: &request::PostOutcome,
now: i64,
) -> Result<String> {
if !outcome.is_success() {
bail!("the token exchange failed with status {}", outcome.status);
}
let tokens = token::parse_token_response(&outcome.json()?)?;
if tokens.sub != pending.did {
bail!(
"the authorization server returned tokens for a different subject than the \
login was started for"
);
}
store::put_session(
pool,
codec,
&store::OAuthSession {
sub: tokens.sub.clone(),
issuer: pending.issuer.clone(),
aud: pending.pds_url.clone(),
dpop_key_jwk: pending.dpop_key_jwk.clone(),
access_token: tokens.access_token,
refresh_token: tokens.refresh_token.unwrap_or_default(),
token_type: tokens.token_type,
granted_scope: tokens.granted_scope,
expires_at: tokens.expires_in.map(|secs| now + secs),
},
)
.await?;
Ok(tokens.sub)
}
fn client_assertion(
runtime: &OauthRuntime,
method: client_auth::AuthMethod,
issuer: &str,
now: i64,
) -> Result<Option<String>> {
match method {
client_auth::AuthMethod::None => Ok(None),
client_auth::AuthMethod::PrivateKeyJwt => {
let key = runtime
.client_key
.as_ref()
.context("private_key_jwt is negotiated but no client signing key is loaded")?;
Ok(Some(client_auth::client_assertion(
key,
&runtime.client_id,
issuer,
now,
)?))
}
}
}
async fn post_form(
http: &reqwest::Client,
pool: &sqlx::SqlitePool,
url: &str,
key: &keys::SigningKey,
params: &[(&'static str, String)],
retry: request::Retry,
) -> Result<request::PostOutcome> {
let borrowed: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect();
request::send_with_dpop(
http,
pool,
&request::DpopRequest {
endpoint: dpop::Endpoint::AuthorizationServer,
url,
key,
access_token: None,
body: request::DpopBody::Form(&borrowed),
retry,
},
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const PUSHED_REDIRECT: &str = "https://feather-reader.com/oauth/callback";
const PENDING_ISSUER: &str = "https://auth.example.com";
const PENDING_DID: &str = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
fn token_body(sub: &str) -> serde_json::Value {
serde_json::json!({
"access_token": "at-abc",
"token_type": "DPoP",
"scope": "atproto",
"sub": sub,
"expires_in": 3600,
"refresh_token": "rt-abc",
})
}
fn outcome(status: u16, body: &serde_json::Value) -> request::PostOutcome {
request::PostOutcome {
status,
body: serde_json::to_vec(body).unwrap(),
}
}
async fn empty_pool() -> sqlx::SqlitePool {
let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
crate::oauth::store::init_schema(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn tokens_for_a_different_subject_are_refused() {
let pool = empty_pool().await;
let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
let pending = pending_auth("unused-hash");
let hostile = token_body("did:plc:zzzzzzzzzzzzzzzzzzzzzzzz");
let err = accept_token_response(
&pool,
&codec,
&pending,
&outcome(200, &hostile),
1_700_000_000,
)
.await
.expect_err("a token response for another DID must be refused");
assert!(
format!("{err:#}").contains("different subject"),
"refused, but not by the subject check: {err:#}",
);
assert!(
crate::oauth::store::get_session(&pool, &codec, "did:plc:zzzzzzzzzzzzzzzzzzzzzzzz")
.await
.unwrap()
.is_none(),
"no session may be stored for a subject the login did not start for",
);
}
#[tokio::test]
async fn tokens_for_the_pending_subject_are_accepted_and_stored() {
let pool = empty_pool().await;
let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
let pending = pending_auth("unused-hash");
let did = accept_token_response(
&pool,
&codec,
&pending,
&outcome(200, &token_body(PENDING_DID)),
1_700_000_000,
)
.await
.expect("a token response for the pending DID must be accepted");
assert_eq!(did, PENDING_DID);
let stored = crate::oauth::store::get_session(&pool, &codec, PENDING_DID)
.await
.unwrap()
.expect("the session must be durable before complete() returns");
assert_eq!(stored.access_token, "at-abc");
assert_eq!(stored.issuer, PENDING_ISSUER);
}
#[tokio::test]
async fn a_failed_token_exchange_stores_nothing() {
let pool = empty_pool().await;
let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
let pending = pending_auth("unused-hash");
let err = accept_token_response(
&pool,
&codec,
&pending,
&outcome(400, &token_body(PENDING_DID)),
1_700_000_000,
)
.await
.expect_err("a 400 must not yield a session");
assert!(
format!("{err:#}").contains("failed with status 400"),
"refused, but not by the status check: {err:#}",
);
assert!(
crate::oauth::store::get_session(&pool, &codec, PENDING_DID)
.await
.unwrap()
.is_none(),
"a failed exchange must leave no session behind",
);
}
#[test]
fn the_token_request_carries_the_pending_rows_pkce_verifier_and_redirect() {
let runtime = runtime_at("https://feather-reader.com");
let pending = pending_auth("unused-hash");
let params = token_exchange_params(
&runtime,
&pending,
"the-code",
runtime.auth_method,
1_700_000_000,
)
.expect("building the token request");
let get = |k: &str| {
params
.iter()
.find(|(name, _)| *name == k)
.map(|(_, v)| v.as_str())
};
assert_eq!(
get("code_verifier"),
Some(pending.pkce_verifier.as_str()),
"the verifier must come from the pending row; anything else forfeits PKCE",
);
assert_eq!(get("code"), Some("the-code"));
assert_eq!(
get("redirect_uri"),
Some(pending.redirect_uri.as_str()),
"the redirect must be the one PAR was pushed under",
);
assert_eq!(get("grant_type"), Some("authorization_code"));
}
#[test]
fn a_failed_par_push_is_not_parsed_as_a_grant() {
let body = serde_json::json!({ "request_uri": "urn:ietf:params:oauth:request_uri:x", "expires_in": 60 });
assert!(
accept_par_response(&outcome(200, &body)).is_ok(),
"the same body at 200 must parse — otherwise this test proves nothing",
);
let err = match accept_par_response(&outcome(400, &body)) {
Ok(_) => panic!("a 400 must not yield a request_uri"),
Err(err) => err,
};
assert!(
format!("{err:#}").contains("failed with status 400"),
"refused, but not by the status check: {err:#}",
);
}
#[test]
fn the_pending_row_lifetime_is_capped_regardless_of_the_server() {
let now = 1_700_000_000;
assert_eq!(
pending_expiry(now, 86_400),
now + MAX_PENDING_SECS,
"a server must not be able to extend the pending row past our cap",
);
assert_eq!(pending_expiry(now, 60), now + 60);
assert_eq!(
pending_expiry(now, MAX_PENDING_SECS),
now + MAX_PENDING_SECS
);
}
fn fixture_dpop_jwk() -> &'static str {
static JWK: std::sync::OnceLock<String> = std::sync::OnceLock::new();
JWK.get_or_init(|| {
crate::oauth::keys::SigningKey::generate("session")
.to_jwk_json()
.unwrap()
})
}
fn fixture_thumbprint() -> String {
crate::oauth::keys::SigningKey::from_jwk_json(fixture_dpop_jwk(), "session")
.unwrap()
.thumbprint()
.unwrap()
}
fn pending_auth(cookie_hash: &str) -> crate::oauth::store::PendingAuth {
crate::oauth::store::PendingAuth {
state: "state-value".into(),
browser_binding_hash: cookie_hash.into(),
pkce_verifier: "verifier".into(),
dpop_key_jwk: fixture_dpop_jwk().to_string(),
issuer: PENDING_ISSUER.into(),
pds_url: "https://pds.example.com".into(),
did: PENDING_DID.into(),
auth_method: "private_key_jwt".into(),
auth_kid: None,
redirect_uri: PUSHED_REDIRECT.into(),
requested_scope: "atproto transition:generic".into(),
request_uri: "urn:x".into(),
app_return_to: None,
expires_at: 2_000_000_000,
}
}
async fn pending_login(cookie_hash: &str) -> sqlx::SqlitePool {
let pool = empty_pool().await;
let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
crate::oauth::store::put_pending(&pool, &codec, &pending_auth(cookie_hash))
.await
.unwrap();
pool
}
fn runtime_at(public_url: &str) -> crate::oauth::runtime::OauthRuntime {
crate::oauth::runtime::OauthRuntime::new(&crate::config::Config {
repo_backend: crate::metrics::Backend::Rust,
public_url: public_url.into(),
oauth: crate::config::OauthConfig {
encryption_key: Some(TEST_KEY.to_string()),
key_path: std::env::temp_dir().join(format!(
"fr-login-test-key-{}-{:p}.json",
std::process::id(),
&TEST_KEY as *const _
)),
plc_directory: "https://plc.invalid".to_string(),
..crate::config::OauthConfig::default()
},
..crate::config::Config::default()
})
.expect("the test runtime must build")
}
const START_PDS: &str = "https://pds.example.com";
const START_ISSUER: &str = "https://auth.example.com";
fn started_account() -> crate::oauth::resolve::ResolvedAccount {
crate::oauth::resolve::ResolvedAccount {
did: PENDING_DID.into(),
pds_url: START_PDS.into(),
handle: Some("alice.example.com".into()),
}
}
fn started_server() -> discovery::AuthorizationServer {
discovery::AuthorizationServer {
issuer: START_ISSUER.into(),
par_endpoint: format!("{START_ISSUER}/par"),
authorization_endpoint: format!("{START_ISSUER}/authorize"),
token_endpoint: format!("{START_ISSUER}/token"),
revocation_endpoint: None,
}
}
fn par_ok() -> request::PostOutcome {
request::PostOutcome {
status: 201,
body: br#"{"request_uri":"urn:ietf:params:oauth:request_uri:abc","expires_in":60}"#
.to_vec(),
}
}
async fn run_start_against(
runtime: &crate::oauth::runtime::OauthRuntime,
pool: &sqlx::SqlitePool,
now: i64,
server: discovery::AuthorizationServer,
par: request::PostOutcome,
) -> (
Result<StartedLogin>,
std::sync::Arc<std::sync::Mutex<Vec<ParPost>>>,
) {
let pushed = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = std::sync::Arc::clone(&pushed);
let r = start_with(
runtime,
pool,
"alice.example.com",
now,
|_subject| async move { Ok(started_account()) },
move |_pds, _method, expected| async move {
assert!(
expected.is_none(),
"the initial push must not claim a prior issuer, got {expected:?}"
);
Ok(server)
},
move |req: ParPost| {
let sink = std::sync::Arc::clone(&sink);
async move {
sink.lock().unwrap().push(req);
Ok(par)
}
},
)
.await;
(r, pushed)
}
async fn run_start(
runtime: &crate::oauth::runtime::OauthRuntime,
pool: &sqlx::SqlitePool,
now: i64,
) -> (
Result<StartedLogin>,
std::sync::Arc<std::sync::Mutex<Vec<ParPost>>>,
) {
run_start_against(runtime, pool, now, started_server(), par_ok()).await
}
fn par_body(status: u16, body: &str) -> request::PostOutcome {
request::PostOutcome {
status,
body: body.as_bytes().to_vec(),
}
}
fn pushed_state(captured: &std::sync::Mutex<Vec<ParPost>>) -> String {
let c = captured.lock().unwrap();
assert_eq!(c.len(), 1, "PAR must be pushed exactly once");
param(&c[0].params, "state")
.expect("state in the push")
.to_string()
}
async fn assert_refused_without_storing(
pool: &sqlx::SqlitePool,
runtime: &crate::oauth::runtime::OauthRuntime,
now: i64,
started: Result<StartedLogin>,
captured: &std::sync::Mutex<Vec<ParPost>>,
what: &str,
) -> Result<()> {
assert!(started.is_err(), "{what} must fail the login");
let state = pushed_state(captured);
assert!(
crate::oauth::store::take_pending(pool, &runtime.codec, &state, now)
.await?
.is_none(),
"{what} left a pending row behind",
);
Ok(())
}
fn param<'a>(params: &'a [(&'static str, String)], k: &str) -> Option<&'a str> {
params
.iter()
.find(|(n, _)| *n == k)
.map(|(_, v)| v.as_str())
}
#[tokio::test]
async fn a_start_pushes_this_clients_identity_and_stores_what_it_pushed() -> Result<()> {
let pool = empty_pool().await;
let runtime = runtime_at("https://app.example.com");
let now = 1_700_000_000;
let (started, pushed) = run_start(&runtime, &pool, now).await;
let started = started?;
let (url, params, pushed_key_jwk, retry_allowed) = {
let pushed = pushed.lock().unwrap();
assert_eq!(pushed.len(), 1, "PAR must be pushed exactly once");
let r = &pushed[0];
(
r.url.clone(),
r.params.clone(),
r.key.to_jwk_json()?,
matches!(r.retry, request::Retry::Allowed),
)
};
let req = ¶ms;
assert_eq!(url, format!("{START_ISSUER}/par"), "wrong PAR endpoint");
assert_eq!(
param(req, "client_id"),
Some(runtime.client_id.as_str()),
"PAR was pushed under a client_id that is not ours",
);
assert_eq!(
param(req, "redirect_uri"),
Some(crate::oauth::metadata::redirect_uri(&runtime.client).as_str()),
);
assert!(
retry_allowed,
"PAR consumes nothing on rejection and must stay retryable",
);
let assertion = param(req, "client_assertion").expect("a client assertion");
let claims: serde_json::Value = {
use base64::Engine as _;
let payload = assertion.split('.').nth(1).expect("a JWT payload segment");
serde_json::from_slice(
&base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.expect("the payload must be base64url"),
)
.expect("the payload must be JSON")
};
assert_eq!(
claims["aud"].as_str(),
Some(START_ISSUER),
"the client assertion is addressed to the wrong audience: {claims}",
);
assert_eq!(
claims["iss"].as_str(),
Some(runtime.client_id.as_str()),
"the client assertion's issuer is not this client",
);
assert_eq!(
claims["sub"].as_str(),
Some(runtime.client_id.as_str()),
"the client assertion's subject is not this client",
);
assert_eq!(
claims["iat"].as_i64(),
Some(now),
"iat is not the passed now"
);
assert!(
claims["exp"]
.as_i64()
.is_some_and(|e| e > now && e <= now + 300),
"exp must be ahead of iat and short-lived: {claims}",
);
let pending = crate::oauth::store::take_pending(
&pool,
&runtime.codec,
param(req, "state").expect("state in the push"),
now,
)
.await?
.expect("the pending row must exist");
assert_eq!(
param(req, "code_challenge"),
Some(flow::pkce_challenge(&pending.pkce_verifier).as_str()),
"the pushed PKCE challenge does not match the stored verifier",
);
assert_eq!(
pending.dpop_key_jwk, pushed_key_jwk,
"the pending row stores a different DPoP key than PAR was signed under",
);
assert_eq!(
pending.browser_binding_hash,
flow::binding_hash(&started.binding_token),
"the stored binding hash is not of the token handed to the browser",
);
assert_eq!(pending.issuer, START_ISSUER);
assert_eq!(pending.pds_url, START_PDS);
assert_eq!(pending.did, PENDING_DID);
assert_eq!(pending.request_uri, "urn:ietf:params:oauth:request_uri:abc");
assert_eq!(
pending.redirect_uri,
crate::oauth::metadata::redirect_uri(&runtime.client)
);
assert_eq!(pending.requested_scope, runtime.client.scope_str());
assert!(
started
.authorize_url
.starts_with(&format!("{START_ISSUER}/authorize")),
"authorize_url does not point at the discovered endpoint: {}",
started.authorize_url,
);
assert!(
started
.authorize_url
.contains("urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Aabc")
|| started
.authorize_url
.contains("request_uri=urn:ietf:params:oauth:request_uri:abc"),
"authorize_url does not carry the issued request_uri: {}",
started.authorize_url,
);
Ok(())
}
#[tokio::test]
async fn a_rejected_par_push_stores_no_pending_row() -> Result<()> {
let pool = empty_pool().await;
let runtime = runtime_at("https://app.example.com");
let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = std::sync::Arc::clone(&captured);
let r = start_with(
&runtime,
&pool,
"alice.example.com",
1_700_000_000,
|_s| async move { Ok(started_account()) },
|_p, _m, _e| async move { Ok(started_server()) },
move |req: ParPost| {
let sink = std::sync::Arc::clone(&sink);
async move {
sink.lock().unwrap().push(req);
Ok(request::PostOutcome {
status: 400,
body: br#"{"error":"invalid_request"}"#.to_vec(),
})
}
},
)
.await;
assert!(r.is_err(), "a 400 from PAR must fail the login");
let state = {
let c = captured.lock().unwrap();
param(&c[0].params, "state").expect("state").to_string()
};
assert!(
crate::oauth::store::take_pending(&pool, &runtime.codec, &state, 1_700_000_000)
.await?
.is_none(),
"a rejected push left a pending row behind",
);
Ok(())
}
#[tokio::test]
async fn a_hostile_request_uri_cannot_smuggle_parameters_into_the_authorize_url() -> Result<()>
{
const SMUGGLED: &str =
"urn:ietf:params:oauth:request_uri:abc&redirect_uri=https://evil.example.com";
let pool = empty_pool().await;
let runtime = runtime_at("https://app.example.com");
let body = serde_json::json!({ "request_uri": SMUGGLED, "expires_in": 60 });
let (started, _) = run_start_against(
&runtime,
&pool,
1_700_000_000,
started_server(),
par_body(201, &body.to_string()),
)
.await;
let url = url::Url::parse(&started?.authorize_url)?;
let pairs: Vec<(String, String)> = url
.query_pairs()
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
assert_eq!(
pairs.len(),
2,
"the authorize URL gained a parameter from the server's bytes: {pairs:?}",
);
assert_eq!(
pairs
.iter()
.find(|(k, _)| k == "request_uri")
.map(|(_, v)| v.as_str()),
Some(SMUGGLED),
"the request_uri must survive as ONE opaque value, not be split",
);
assert!(
!pairs.iter().any(|(k, _)| k == "redirect_uri"),
"a redirect_uri was smuggled into the authorize URL: {pairs:?}",
);
Ok(())
}
#[tokio::test]
async fn a_par_success_carrying_no_grant_stores_nothing() -> Result<()> {
let pool = empty_pool().await;
let runtime = runtime_at("https://app.example.com");
let now = 1_700_000_000;
let (started, captured) = run_start_against(
&runtime,
&pool,
now,
started_server(),
par_body(200, r#"{"error":"invalid_request"}"#),
)
.await;
assert_refused_without_storing(
&pool,
&runtime,
now,
started,
&captured,
"a 200 with no request_uri",
)
.await
}
#[tokio::test]
async fn a_par_response_that_is_not_json_stores_nothing() -> Result<()> {
let pool = empty_pool().await;
let runtime = runtime_at("https://app.example.com");
let now = 1_700_000_000;
let (started, captured) = run_start_against(
&runtime,
&pool,
now,
started_server(),
par_body(200, "<html><body>502 Bad Gateway</body></html>"),
)
.await;
assert_refused_without_storing(
&pool,
&runtime,
now,
started,
&captured,
"a non-JSON PAR body",
)
.await
}
#[tokio::test]
async fn a_non_positive_par_lifetime_is_refused_rather_than_stored() -> Result<()> {
let runtime = runtime_at("https://app.example.com");
let now = 1_700_000_000;
for expires_in in ["0", "-1"] {
let pool = empty_pool().await;
let body = format!(
r#"{{"request_uri":"urn:ietf:params:oauth:request_uri:abc","expires_in":{expires_in}}}"#
);
let (started, captured) =
run_start_against(&runtime, &pool, now, started_server(), par_body(201, &body))
.await;
assert_refused_without_storing(
&pool,
&runtime,
now,
started,
&captured,
&format!("expires_in={expires_in}"),
)
.await?;
}
Ok(())
}
#[tokio::test]
async fn a_server_cannot_pin_a_pending_login_beyond_the_cap() -> Result<()> {
let pool = empty_pool().await;
let runtime = runtime_at("https://app.example.com");
let now = 1_700_000_000;
let (started, captured) = run_start_against(
&runtime,
&pool,
now,
started_server(),
par_body(
201,
r#"{"request_uri":"urn:ietf:params:oauth:request_uri:abc","expires_in":1000000000}"#,
),
)
.await;
started?;
let state = pushed_state(&captured);
let pending = crate::oauth::store::take_pending(&pool, &runtime.codec, &state, now)
.await?
.expect("the pending row must exist");
assert_eq!(
pending.expires_at,
now + MAX_PENDING_SECS,
"the server's lifetime was accepted instead of our cap",
);
Ok(())
}
#[tokio::test]
async fn a_non_https_authorization_endpoint_fails_the_login() -> Result<()> {
let pool = empty_pool().await;
let runtime = runtime_at("https://app.example.com");
let downgraded = discovery::AuthorizationServer {
authorization_endpoint: "http://auth.example.com/authorize".into(),
..started_server()
};
let (started, _) = run_start_against(
&runtime,
&pool,
1_700_000_000,
downgraded,
par_body(
201,
r#"{"request_uri":"urn:ietf:params:oauth:request_uri:abc","expires_in":60}"#,
),
)
.await;
let Err(err) = started else {
panic!("an http authorization endpoint must fail the login")
};
assert!(
format!("{err:#}").contains("https"),
"the failure should name the scheme, got: {err:#}",
);
Ok(())
}
fn callback_params() -> flow::CallbackParams {
flow::CallbackParams {
code: Some("the-code".into()),
state: Some("state-value".into()),
iss: Some(PENDING_ISSUER.into()),
error: None,
error_description: None,
response: None,
}
}
#[tokio::test]
async fn a_login_started_under_a_different_public_url_is_refused() {
let cookie = flow::new_binding_token();
let pool = pending_login(&flow::binding_hash(&cookie)).await;
let runtime = runtime_at("http://127.0.0.1:8080");
let err = complete(
&runtime,
&reqwest::Client::new(),
&pool,
&callback_params(),
Some(&cookie),
1_700_000_000,
)
.await
.expect_err("a client-identity change mid-flight must refuse the exchange");
let rendered = format!("{err:#}");
assert!(
rendered.contains("started under a different public URL")
&& rendered.contains(PUSHED_REDIRECT),
"refused, but not BY the client-identity check — this is the failure mode \
where discovery merely errored instead: {rendered}",
);
}
#[tokio::test]
async fn a_matching_public_url_passes_the_identity_check() {
let cookie = flow::new_binding_token();
let pool = pending_login(&flow::binding_hash(&cookie)).await;
let runtime = runtime_at("https://feather-reader.com");
let err = complete(
&runtime,
&reqwest::Client::new(),
&pool,
&callback_params(),
Some(&cookie),
1_700_000_000,
)
.await
.expect_err("discovery cannot reach pds.example.com from a test");
let rendered = format!("{err:#}");
assert!(
rendered.contains("oauth-protected-resource") && rendered.contains("resolving host"),
"the exchange should have got as far as discovery: {rendered}"
);
assert!(
!rendered.contains("started under a different public URL"),
"a login whose public URL never changed must not be refused as though it \
had; the identity check is rejecting valid logins: {rendered}",
);
}
#[derive(Default)]
struct Calls {
discovered: Vec<(String, Option<String>, String)>,
posted: Vec<PostedCall>,
resolved: Vec<String>,
}
type Log = std::sync::Arc<std::sync::Mutex<Calls>>;
struct PostedCall {
url: String,
params: Vec<(&'static str, String)>,
dpop_thumbprint: String,
retry: request::Retry,
}
const DISCOVERED_TOKEN_ENDPOINT: &str = "https://token.example.net/oauth/v2/token";
fn server_at(issuer: &str) -> crate::oauth::discovery::AuthorizationServer {
crate::oauth::discovery::AuthorizationServer {
issuer: issuer.into(),
par_endpoint: format!("{issuer}/par"),
authorization_endpoint: format!("{issuer}/authorize"),
token_endpoint: DISCOVERED_TOKEN_ENDPOINT.to_string(),
revocation_endpoint: None,
}
}
async fn drive(
pool: &sqlx::SqlitePool,
runtime: &crate::oauth::runtime::OauthRuntime,
params: &flow::CallbackParams,
cookie: Option<&str>,
discovered_issuer: &str,
token_status: u16,
token_body: serde_json::Value,
) -> (Result<CompletedLogin>, Log) {
let log: Log = Default::default();
let (s1, s2, s3) = (
std::sync::Arc::clone(&log),
std::sync::Arc::clone(&log),
std::sync::Arc::clone(&log),
);
let issuer = discovered_issuer.to_string();
let body = serde_json::to_vec(&token_body).unwrap();
let out = complete_with(
runtime,
pool,
params,
cookie,
1_700_000_000,
move |req| {
let sink = std::sync::Arc::clone(&s1);
let issuer = issuer.clone();
async move {
sink.lock().unwrap().discovered.push((
req.pds_url,
req.expected_issuer,
req.auth_method,
));
Ok(server_at(&issuer))
}
},
move |req: TokenPost| {
let sink = std::sync::Arc::clone(&s2);
let body = body.clone();
async move {
let tp = req.key.thumbprint().unwrap_or_default();
sink.lock().unwrap().posted.push(PostedCall {
url: req.url,
params: req.params,
dpop_thumbprint: tp,
retry: req.retry,
});
Ok(request::PostOutcome {
status: token_status,
body,
})
}
},
move |did| {
let sink = std::sync::Arc::clone(&s3);
async move {
sink.lock().unwrap().resolved.push(did);
Err(anyhow::anyhow!("handle resolution unavailable in tests"))
}
},
)
.await;
(out, log)
}
#[tokio::test]
async fn a_full_callback_stores_a_session() {
let cookie = flow::new_binding_token();
let pool = pending_login(&flow::binding_hash(&cookie)).await;
let runtime = runtime_at("https://feather-reader.com");
let (out, log) = drive(
&pool,
&runtime,
&callback_params(),
Some(&cookie),
PENDING_ISSUER,
200,
token_body(PENDING_DID),
)
.await;
let done = out.expect("the full callback should complete");
assert_eq!(done.did, PENDING_DID);
assert!(
done.handle.is_none(),
"the handle lookup was expected to fail offline"
);
let (
discoveries,
expected_issuer,
auth_method,
posts,
token_url,
thumbprint,
retry,
token_params,
) = {
let calls = log.lock().unwrap();
(
calls.discovered.len(),
calls.discovered[0].1.clone(),
calls.discovered[0].2.clone(),
calls.posted.len(),
calls.posted[0].url.clone(),
calls.posted[0].dpop_thumbprint.clone(),
calls.posted[0].retry,
calls.posted[0].params.clone(),
)
};
assert_eq!(discoveries, 1, "discovery ran once");
assert_eq!(
auth_method, "private_key_jwt",
"discovery was told the wrong auth method; `none` would disable the \
token_endpoint_auth_methods_supported check and the server would \
then receive a private_key_jwt assertion it never advertised",
);
assert_eq!(
expected_issuer.as_deref(),
Some(PENDING_ISSUER),
"the mix-up re-check was not armed: discovery got {expected_issuer:?}",
);
assert_eq!(posts, 1, "the token exchange ran once");
assert_eq!(
token_url, DISCOVERED_TOKEN_ENDPOINT,
"the grant went to an endpoint guessed from the issuer rather than \
the one discovery returned",
);
assert_eq!(
retry,
request::Retry::Allowed,
"the token POST must be retryable: a nonce challenge is rejected \
before the grant is processed, so the code is not consumed",
);
assert!(
token_params
.iter()
.any(|(k, v)| *k == "code" && v == "the-code"),
"the token request did not carry the callback's authorization code: {token_params:?}",
);
assert_eq!(
thumbprint,
fixture_thumbprint(),
"the token request was signed under a different key than the one the \
authorization request was bound to",
);
assert_eq!(
log.lock().unwrap().resolved,
vec![PENDING_DID.to_string()],
"the handle lookup did not run exactly once for this subject",
);
let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
let stored = crate::oauth::store::get_session(&pool, &codec, PENDING_DID)
.await
.unwrap()
.expect("no session was stored for a successful login");
let stored_thumbprint = keys::SigningKey::from_jwk_json(&stored.dpop_key_jwk, "session")
.expect("the stored session's DPoP key does not parse")
.thumbprint()
.unwrap();
assert_eq!(
stored_thumbprint,
fixture_thumbprint(),
"the session persisted a different DPoP key than the grant is bound to",
);
assert_eq!(stored.access_token, "at-abc");
assert_eq!(
stored.refresh_token, "rt-abc",
"an empty refresh token stores an un-refreshable session: the first \
refresh presents \"\" and the server's invalid_grant deletes it, \
which is the spurious logout the token module exists to avoid",
);
assert_eq!(stored.token_type, "DPoP");
assert_eq!(
stored.granted_scope, "atproto",
"the session stored the REQUESTED scope, not the granted one — a \
narrowed grant must be visible now rather than as a mystery write \
failure later",
);
assert_eq!(
stored.expires_at,
Some(1_700_000_000 + 3600),
"the session's expiry is not the token's; `None` means `is_stale` is \
never true, so it is never proactively refreshed and simply dies",
);
assert_eq!(stored.issuer, PENDING_ISSUER);
}
#[tokio::test]
async fn a_mismatched_iss_posts_nothing_anywhere() {
let cookie = flow::new_binding_token();
let pool = pending_login(&flow::binding_hash(&cookie)).await;
let runtime = runtime_at("https://feather-reader.com");
let mut params = callback_params();
params.iss = Some("https://evil.example.com".into());
let (out, log) = drive(
&pool,
&runtime,
¶ms,
Some(&cookie),
PENDING_ISSUER,
200,
token_body(PENDING_DID),
)
.await;
assert!(out.is_err(), "a mismatched iss completed the login");
let calls = log.lock().unwrap();
assert!(
calls.posted.is_empty(),
"the authorization code was posted despite a bad iss: {:?}",
calls.posted.iter().map(|p| &p.url).collect::<Vec<_>>(),
);
assert!(
calls.discovered.is_empty(),
"discovery ran before the iss check",
);
}
#[tokio::test]
async fn a_foreign_browser_posts_nothing_anywhere() {
let cookie = flow::new_binding_token();
let pool = pending_login(&flow::binding_hash(&cookie)).await;
let runtime = runtime_at("https://feather-reader.com");
let (out, log) = drive(
&pool,
&runtime,
&callback_params(),
Some("a-different-browser"),
PENDING_ISSUER,
200,
token_body(PENDING_DID),
)
.await;
assert!(out.is_err(), "a foreign browser completed the login");
let calls = log.lock().unwrap();
assert!(calls.posted.is_empty());
assert!(
calls.discovered.is_empty(),
"discovery ran before the browser binding was checked",
);
}
#[tokio::test]
async fn discovery_is_given_the_stored_issuer_to_expect() {
let cookie = flow::new_binding_token();
let pool = pending_login(&flow::binding_hash(&cookie)).await;
let runtime = runtime_at("https://feather-reader.com");
let (out, log) = drive(
&pool,
&runtime,
&callback_params(),
Some(&cookie),
PENDING_ISSUER,
200,
token_body(PENDING_DID),
)
.await;
assert!(out.is_ok());
let calls = log.lock().unwrap();
assert_eq!(
calls.discovered[0].1.as_deref(),
Some(PENDING_ISSUER),
"discovery was not told which issuer to expect; the mix-up defence \
is disabled from the caller's side",
);
assert_eq!(
calls.discovered[0].0, "https://pds.example.com",
"discovery was pointed at something other than the stored PDS",
);
}
#[tokio::test]
async fn a_replayed_callback_is_refused_and_posts_nothing() {
let cookie = flow::new_binding_token();
let pool = pending_login(&flow::binding_hash(&cookie)).await;
let runtime = runtime_at("https://feather-reader.com");
let first = drive(
&pool,
&runtime,
&callback_params(),
Some(&cookie),
PENDING_ISSUER,
200,
token_body(PENDING_DID),
)
.await;
assert!(first.0.is_ok(), "the first callback should succeed");
let (out, log) = drive(
&pool,
&runtime,
&callback_params(),
Some(&cookie),
PENDING_ISSUER,
200,
token_body(PENDING_DID),
)
.await;
assert!(out.is_err(), "the callback was replayable");
assert!(
log.lock().unwrap().posted.is_empty(),
"a replayed callback still reached the token endpoint",
);
}
#[tokio::test]
async fn a_corrupt_dpop_key_posts_nothing_anywhere() {
let cookie = flow::new_binding_token();
let pool = empty_pool().await;
let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
let mut pending = pending_auth(&flow::binding_hash(&cookie));
pending.dpop_key_jwk = "{\"kty\":\"EC\",\"crv\":\"bogus\"}".into();
crate::oauth::store::put_pending(&pool, &codec, &pending)
.await
.unwrap();
let runtime = runtime_at("https://feather-reader.com");
let (out, log) = drive(
&pool,
&runtime,
&callback_params(),
Some(&cookie),
PENDING_ISSUER,
200,
token_body(PENDING_DID),
)
.await;
assert!(out.is_err(), "a corrupt DPoP key completed the login");
let calls = log.lock().unwrap();
assert!(
calls.posted.is_empty(),
"the authorization code was posted before the DPoP key was checked",
);
assert!(
calls.discovered.is_empty(),
"discovery ran before the DPoP key was checked",
);
}
fn discovery_routes(
pds: &str,
issuer: &str,
) -> std::collections::HashMap<String, Vec<crate::net::TestResponse>> {
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"),
"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
}
async fn pending_against(pds: &str, issuer: &str, cookie: &str) -> sqlx::SqlitePool {
let pool = empty_pool().await;
let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
let mut pending = pending_auth(&flow::binding_hash(cookie));
pending.pds_url = pds.to_string();
pending.issuer = issuer.to_string();
crate::oauth::store::put_pending(&pool, &codec, &pending)
.await
.unwrap();
pool
}
#[tokio::test]
async fn a_repointed_authorization_server_is_refused_before_the_code_is_posted() {
let cookie = flow::new_binding_token();
let (addr, log) = crate::net::spawn_tls(|addr| {
let port = addr.port();
discovery_routes(
&format!("https://pds-e2e.test:{port}"),
&format!("https://as-evil.test:{port}"),
)
})
.await;
for h in ["pds-e2e.test", "as-e2e.test", "as-evil.test"] {
crate::net::test_host_override(h, addr);
}
let port = addr.port();
let pds = format!("https://pds-e2e.test:{port}");
let honest = format!("https://as-e2e.test:{port}");
let pool = pending_against(&pds, &honest, &cookie).await;
let runtime = runtime_at("https://feather-reader.com");
let mut params = callback_params();
params.iss = Some(honest.clone());
let err = complete(
&runtime,
&reqwest::Client::builder().build().unwrap(),
&pool,
¶ms,
Some(&cookie),
1_700_000_000,
)
.await
.expect_err("a repointed authorization server completed the login");
let rendered = format!("{err:#}");
let lower = rendered.to_ascii_lowercase();
assert!(
lower.contains("different authorization server")
&& rendered.contains("as-evil.test")
&& rendered.contains("as-e2e.test"),
"refused, but not by the mix-up check: {rendered}",
);
let seen = log.lock().unwrap().join("\n");
assert!(
!seen.contains("POST /token"),
"the authorization code was posted to a server the user never \
approved:\n{seen}",
);
}
#[tokio::test]
async fn a_well_formed_discovery_over_tls_reaches_the_token_endpoint() {
let cookie = flow::new_binding_token();
let (addr, log) = crate::net::spawn_tls(|addr| {
let port = addr.port();
let pds = format!("https://pds-e2e.test:{port}");
let issuer = format!("https://as-e2e.test:{port}");
let mut r = discovery_routes(&pds, &issuer);
r.insert(
"/token".to_string(),
vec![crate::net::TestResponse::json(
400,
"{\"error\":\"invalid_grant\"}",
)],
);
r
})
.await;
for h in ["pds-e2e.test", "as-e2e.test"] {
crate::net::test_host_override(h, addr);
}
let port = addr.port();
let pds = format!("https://pds-e2e.test:{port}");
let issuer = format!("https://as-e2e.test:{port}");
let pool = pending_against(&pds, &issuer, &cookie).await;
let runtime = runtime_at("https://feather-reader.com");
let mut params = callback_params();
params.iss = Some(issuer.clone());
let _ = complete(
&runtime,
&reqwest::Client::builder().build().unwrap(),
&pool,
¶ms,
Some(&cookie),
1_700_000_000,
)
.await;
let seen = log.lock().unwrap().join("\n");
assert!(
seen.contains("/.well-known/oauth-protected-resource"),
"discovery never fetched the protected-resource document:\n{seen}",
);
assert!(
seen.contains("POST /token"),
"a well-formed discovery never reached the token endpoint — the \
refusal test above would pass for the wrong reason:\n{seen}",
);
}
fn dpop_proof(raw: &str) -> String {
raw.lines()
.find(|l| l.to_ascii_lowercase().starts_with("dpop:"))
.expect("no DPoP header on the request")[5..]
.trim()
.to_string()
}
fn jwt_part(jwt: &str, idx: usize) -> serde_json::Value {
use base64::Engine as _;
let seg = jwt.split('.').nth(idx).expect("malformed JWT");
let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(seg)
.expect("JWT segment is not base64url");
serde_json::from_slice(&raw).expect("JWT segment is not JSON")
}
fn dpop_jwk_from(raw: &str) -> String {
use base64::Engine as _;
let line = raw
.lines()
.find(|l| l.to_ascii_lowercase().starts_with("dpop:"))
.expect("no DPoP header on the token request");
let jwt = line[5..].trim();
let header_b64 = jwt.split('.').next().expect("malformed DPoP proof");
let json = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(header_b64)
.expect("DPoP header is not base64url");
let v: serde_json::Value = serde_json::from_slice(&json).expect("DPoP header is not JSON");
v.get("jwk")
.expect("DPoP header carries no jwk")
.to_string()
}
#[tokio::test]
async fn the_token_request_is_signed_under_the_pending_rows_key() {
let cookie = flow::new_binding_token();
let (addr, log) = crate::net::spawn_tls(|addr| {
let port = addr.port();
let mut r = discovery_routes(
&format!("https://pds-e2e.test:{port}"),
&format!("https://as-e2e.test:{port}"),
);
r.insert(
"/token".to_string(),
vec![crate::net::TestResponse::json(
400,
"{\"error\":\"invalid_grant\"}",
)],
);
r
})
.await;
for h in ["pds-e2e.test", "as-e2e.test"] {
crate::net::test_host_override(h, addr);
}
let port = addr.port();
let issuer = format!("https://as-e2e.test:{port}");
let pool = pending_against(&format!("https://pds-e2e.test:{port}"), &issuer, &cookie).await;
let runtime = runtime_at("https://feather-reader.com");
let mut params = callback_params();
params.iss = Some(issuer);
let _ = complete(
&runtime,
&reqwest::Client::builder().build().unwrap(),
&pool,
¶ms,
Some(&cookie),
1_700_000_000,
)
.await;
let token_req = log
.lock()
.unwrap()
.iter()
.find(|r| r.starts_with("POST /token"))
.cloned()
.expect("the token endpoint was never reached");
let on_the_wire =
keys::SigningKey::public_thumbprint_of(&dpop_jwk_from(&token_req)).unwrap();
assert_eq!(
on_the_wire,
fixture_thumbprint(),
"the token request was signed under a key the authorization request \
was never bound to",
);
}
#[tokio::test]
async fn a_nonce_challenge_on_the_token_endpoint_is_retried() {
let cookie = flow::new_binding_token();
let (addr, log) = crate::net::spawn_tls(|addr| {
let port = addr.port();
let mut r = discovery_routes(
&format!("https://pds-e2e.test:{port}"),
&format!("https://as-e2e.test:{port}"),
);
r.insert(
"/token".to_string(),
vec![
crate::net::TestResponse::json(400, "{\"error\":\"use_dpop_nonce\"}")
.with_header("DPoP-Nonce", "nonce-from-the-server"),
crate::net::TestResponse::json(400, "{\"error\":\"invalid_grant\"}"),
],
);
r
})
.await;
for h in ["pds-e2e.test", "as-e2e.test"] {
crate::net::test_host_override(h, addr);
}
let port = addr.port();
let issuer = format!("https://as-e2e.test:{port}");
let pool = pending_against(&format!("https://pds-e2e.test:{port}"), &issuer, &cookie).await;
let runtime = runtime_at("https://feather-reader.com");
let mut params = callback_params();
params.iss = Some(issuer);
let _ = complete(
&runtime,
&reqwest::Client::builder().build().unwrap(),
&pool,
¶ms,
Some(&cookie),
1_700_000_000,
)
.await;
let reqs = log.lock().unwrap().clone();
let token_hits = reqs.iter().filter(|r| r.starts_with("POST /token")).count();
assert_eq!(
token_hits, 2,
"a use_dpop_nonce challenge was not retried; the exchange is marked \
non-retryable somewhere between complete and the wire",
);
let second = reqs
.iter()
.filter(|r| r.starts_with("POST /token"))
.nth(1)
.unwrap();
let claims = jwt_part(&dpop_proof(second), 1);
assert_eq!(
claims.get("nonce").and_then(|v| v.as_str()),
Some("nonce-from-the-server"),
"the retry did not carry the server's nonce; it would be challenged \
again forever",
);
}
#[tokio::test]
async fn the_exchange_uses_the_auth_method_the_login_started_under() {
let cookie = flow::new_binding_token();
let pool = empty_pool().await;
let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
let mut pending = pending_auth(&flow::binding_hash(&cookie));
pending.auth_method = "none".into();
crate::oauth::store::put_pending(&pool, &codec, &pending)
.await
.unwrap();
let runtime = runtime_at("https://feather-reader.com");
assert_eq!(
runtime.auth_method.as_str(),
"private_key_jwt",
"fixture: the runtime must DISAGREE with the pending row",
);
let (out, log) = drive(
&pool,
&runtime,
&callback_params(),
Some(&cookie),
PENDING_ISSUER,
200,
token_body(PENDING_DID),
)
.await;
assert!(out.is_ok(), "the exchange should complete: {out:?}");
let calls = log.lock().unwrap();
assert_eq!(
calls.discovered[0].2, "none",
"discovery was told the runtime's method, not the one PAR was pushed \
under",
);
let params = &calls.posted[0].params;
assert!(
!params.iter().any(|(k, _)| *k == "client_assertion"),
"a private_key_jwt assertion was sent for a login started under \
`none`: {params:?}",
);
}
#[tokio::test]
async fn the_assertion_and_session_audiences_are_distinct_and_correct() {
let cookie = flow::new_binding_token();
let pool = pending_login(&flow::binding_hash(&cookie)).await;
let runtime = runtime_at("https://feather-reader.com");
let (out, log) = drive(
&pool,
&runtime,
&callback_params(),
Some(&cookie),
PENDING_ISSUER,
200,
token_body(PENDING_DID),
)
.await;
assert!(out.is_ok());
let assertion = {
let calls = log.lock().unwrap();
calls.posted[0]
.params
.iter()
.find(|(k, _)| *k == "client_assertion")
.map(|(_, v)| v.clone())
.expect("no client_assertion for a private_key_jwt login")
};
let claims: serde_json::Value = {
use base64::Engine as _;
let seg = assertion.split('.').nth(1).expect("malformed assertion");
let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(seg)
.expect("assertion payload is not base64url");
serde_json::from_slice(&raw).expect("assertion payload is not JSON")
};
assert_eq!(
claims.get("aud").and_then(|v| v.as_str()),
Some(PENDING_ISSUER),
"the assertion was minted for the wrong audience; its anti-replay \
binding names a server it was not sent to",
);
let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
let stored = crate::oauth::store::get_session(&pool, &codec, PENDING_DID)
.await
.unwrap()
.expect("session");
assert_eq!(
stored.aud, "https://pds.example.com",
"the session's audience is not the PDS; every later DPoP-bound call \
would carry the wrong `htu`/`aud`",
);
}
#[tokio::test]
async fn an_expired_pending_row_is_refused() {
let cookie = flow::new_binding_token();
let pool = empty_pool().await;
let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
let mut pending = pending_auth(&flow::binding_hash(&cookie));
pending.expires_at = 1_700_000_000 - 3600;
crate::oauth::store::put_pending(&pool, &codec, &pending)
.await
.unwrap();
let runtime = runtime_at("https://feather-reader.com");
let (out, log) = drive(
&pool,
&runtime,
&callback_params(),
Some(&cookie),
PENDING_ISSUER,
200,
token_body(PENDING_DID),
)
.await;
assert!(out.is_err(), "an expired pending row completed a login");
assert!(
log.lock().unwrap().posted.is_empty(),
"the authorization code was posted for an expired pending row",
);
}
}