use anyhow::{anyhow, bail, Context as _, Result};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use serde_json::{json, Map, Value};
use super::jwt;
use super::keys::SigningKey;
const NONCE_CHALLENGE: &str = "use_dpop_nonce";
fn new_jti() -> String {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).expect("OS CSPRNG unavailable; refusing to mint a DPoP proof");
URL_SAFE_NO_PAD.encode(bytes)
}
fn is_tchar(b: u8) -> bool {
b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)
}
fn htu(url: &str) -> Result<String> {
let mut parsed = url::Url::parse(url).with_context(|| format!("not a valid URL {url:?}"))?;
if !matches!(parsed.scheme(), "http" | "https") {
bail!("DPoP target must be http(s), got {:?}", parsed.scheme());
}
parsed
.set_username("")
.map_err(|()| anyhow!("cannot strip userinfo from the DPoP target"))?;
parsed
.set_password(None)
.map_err(|()| anyhow!("cannot strip userinfo from the DPoP target"))?;
parsed.set_query(None);
parsed.set_fragment(None);
Ok(parsed.to_string())
}
fn embedded_public_jwk(key: &SigningKey) -> Result<Value> {
let full = key.public_jwk()?;
let mut minimal = Map::new();
for name in ["kty", "crv", "x", "y"] {
let value = full
.get(name)
.cloned()
.with_context(|| format!("public JWK is missing `{name}`"))?;
minimal.insert(name.to_string(), value);
}
Ok(Value::Object(minimal))
}
pub fn proof(
key: &SigningKey,
method: &str,
url: &str,
access_token: Option<&str>,
nonce: Option<&str>,
) -> Result<String> {
let header = json!({
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": embedded_public_jwk(key)?,
});
if method.is_empty() || !method.bytes().all(is_tchar) {
bail!("{method:?} is not a valid HTTP method token");
}
let mut claims = Map::new();
claims.insert("jti".into(), json!(new_jti()));
claims.insert("htm".into(), json!(method.to_ascii_uppercase()));
claims.insert("htu".into(), json!(htu(url)?));
claims.insert("iat".into(), json!(chrono::Utc::now().timestamp()));
if let Some(token) = access_token {
let digest = ring::digest::digest(&ring::digest::SHA256, token.as_bytes());
claims.insert("ath".into(), json!(URL_SAFE_NO_PAD.encode(digest.as_ref())));
}
if let Some(nonce) = nonce {
claims.insert("nonce".into(), json!(nonce));
}
jwt::sign(key, &header, &Value::Object(claims))
}
struct Challenge {
scheme: String,
params: Vec<(String, String)>,
}
fn split_segments(header: &str) -> Option<Vec<String>> {
let mut out = Vec::new();
let mut current = String::new();
let mut chars = header.chars();
let mut in_quotes = false;
while let Some(c) = chars.next() {
if in_quotes {
match c {
'\\' => {
current.push('\\');
current.push(chars.next()?);
}
'"' => {
in_quotes = false;
current.push(c);
}
_ => current.push(c),
}
} else {
match c {
'"' => {
in_quotes = true;
current.push(c);
}
',' => out.push(std::mem::take(&mut current)),
_ => current.push(c),
}
}
}
if in_quotes {
return None;
}
out.push(current);
Some(out)
}
fn first_unquoted_eq(s: &str) -> Option<usize> {
let mut in_quotes = false;
let mut escaped = false;
for (i, c) in s.char_indices() {
if in_quotes {
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
in_quotes = false;
}
} else if c == '"' {
in_quotes = true;
} else if c == '=' {
return Some(i);
}
}
None
}
fn unquote(raw: &str) -> Option<String> {
let Some(inner) = raw.strip_prefix('"') else {
if raw.is_empty() || raw.contains('"') || raw.chars().any(char::is_whitespace) {
return None;
}
return Some(raw.to_string());
};
let inner = inner.strip_suffix('"')?;
let mut out = String::new();
let mut chars = inner.chars();
while let Some(c) = chars.next() {
match c {
'\\' => out.push(chars.next()?),
'"' => return None,
_ => out.push(c),
}
}
Some(out)
}
fn parse_challenges(header: &str) -> Option<Vec<Challenge>> {
let mut challenges: Vec<Challenge> = Vec::new();
for segment in split_segments(header)? {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
let Some(eq) = first_unquoted_eq(segment) else {
challenges.push(Challenge {
scheme: segment.to_string(),
params: Vec::new(),
});
continue;
};
let left = segment[..eq].trim();
let Some(value) = unquote(segment[eq + 1..].trim()) else {
if let Some((scheme, _)) = left.split_once(char::is_whitespace) {
challenges.push(Challenge {
scheme: scheme.trim().to_string(),
params: Vec::new(),
});
}
continue;
};
match left.split_once(char::is_whitespace) {
Some((scheme, name)) => challenges.push(Challenge {
scheme: scheme.trim().to_string(),
params: vec![(name.trim().to_ascii_lowercase(), value)],
}),
None => challenges
.last_mut()?
.params
.push((left.to_ascii_lowercase(), value)),
}
}
Some(challenges)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Endpoint {
AuthorizationServer,
ResourceServer,
}
fn body_asks_for_nonce(body: &[u8]) -> bool {
if !super::error_body_worth_parsing(body) {
return false;
}
serde_json::from_slice::<Value>(body)
.ok()
.as_ref()
.and_then(|v| v.get("error"))
.and_then(Value::as_str)
== Some(NONCE_CHALLENGE)
}
fn header_asks_for_nonce(www_authenticate: &str) -> bool {
let Some(challenges) = parse_challenges(www_authenticate) else {
return false;
};
challenges.iter().any(|c| {
c.scheme.eq_ignore_ascii_case("DPoP")
&& c.params
.iter()
.any(|(name, value)| name == "error" && value == NONCE_CHALLENGE)
})
}
pub fn nonce_challenge(
endpoint: Endpoint,
status: u16,
www_authenticate: Option<&str>,
body: &[u8],
dpop_nonce: Option<&str>,
) -> Option<String> {
let nonce = dpop_nonce.filter(|n| !n.is_empty())?;
let asked = match endpoint {
Endpoint::AuthorizationServer => status == 400 && body_asks_for_nonce(body),
Endpoint::ResourceServer => {
status == 401 && www_authenticate.is_some_and(header_asks_for_nonce)
}
};
asked.then(|| nonce.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oauth::jwt::verify;
const KID: &str = "dpop-1";
fn parts(jws: &str) -> (Value, Value) {
let seg: Vec<&str> = jws.split('.').collect();
(
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(seg[0]).unwrap()).unwrap(),
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(seg[1]).unwrap()).unwrap(),
)
}
#[test]
fn the_proof_header_is_a_dpop_jwt_with_an_embedded_public_key() {
let key = SigningKey::generate(KID);
let proof = proof(&key, "POST", "https://bsky.social/oauth/token", None, None).unwrap();
let (header, _) = parts(&proof);
assert_eq!(header["typ"], "dpop+jwt");
assert_eq!(header["alg"], "ES256");
assert_eq!(header["jwk"]["kty"], "EC");
assert_eq!(header["jwk"]["crv"], "P-256");
assert!(header["jwk"]["x"].is_string());
assert!(header["jwk"]["y"].is_string());
}
#[test]
fn the_embedded_jwk_never_carries_the_private_scalar() {
let key = SigningKey::generate(KID);
let proof = proof(&key, "GET", "https://bsky.social/xrpc/x", None, None).unwrap();
let (header, _) = parts(&proof);
assert!(
header["jwk"].get("d").is_none(),
"private scalar in DPoP header"
);
let rendered = serde_json::to_string(&header).unwrap();
assert!(
!rendered.contains("\"d\""),
"private scalar in header: {rendered}"
);
}
#[test]
fn the_embedded_jwk_is_the_minimal_public_key() {
let key = SigningKey::generate(KID);
let (header, _) = parts(&proof(&key, "GET", "https://x.example/a", None, None).unwrap());
let members: Vec<&String> = header["jwk"].as_object().unwrap().keys().collect();
assert_eq!(members.len(), 4, "unexpected members: {members:?}");
}
#[test]
fn htu_strips_the_query_and_fragment() {
let key = SigningKey::generate(KID);
for (url, want) in [
(
"https://bsky.social/oauth/token?a=1&b=2",
"https://bsky.social/oauth/token",
),
(
"https://bsky.social/xrpc/get#frag",
"https://bsky.social/xrpc/get",
),
("https://bsky.social/x?q=1#f", "https://bsky.social/x"),
("https://bsky.social/plain", "https://bsky.social/plain"),
] {
let (_, claims) = parts(&proof(&key, "GET", url, None, None).unwrap());
assert_eq!(claims["htu"], want, "for {url}");
}
}
#[test]
fn htm_carries_the_method_and_iat_is_current() {
let key = SigningKey::generate(KID);
let (_, claims) = parts(&proof(&key, "POST", "https://x.example/t", None, None).unwrap());
assert_eq!(claims["htm"], "POST");
let now = chrono::Utc::now().timestamp();
let iat = claims["iat"].as_i64().unwrap();
assert!((now - iat).abs() < 5, "iat {iat} is not close to {now}");
}
#[test]
fn every_proof_gets_a_fresh_unpredictable_jti() {
let key = SigningKey::generate(KID);
let mut seen = std::collections::HashSet::new();
for _ in 0..64 {
let (_, claims) =
parts(&proof(&key, "GET", "https://x.example/a", None, None).unwrap());
let jti = claims["jti"].as_str().unwrap().to_string();
assert!(jti.len() >= 22, "jti too short to be unguessable: {jti}");
assert!(seen.insert(jti), "jti repeated");
}
}
#[test]
fn ath_is_the_base64url_sha256_of_the_access_token_when_present() {
let key = SigningKey::generate(KID);
let token = "an-access-token";
let (_, claims) =
parts(&proof(&key, "GET", "https://x.example/a", Some(token), None).unwrap());
let want = URL_SAFE_NO_PAD
.encode(ring::digest::digest(&ring::digest::SHA256, token.as_bytes()).as_ref());
assert_eq!(claims["ath"], want);
}
#[test]
fn ath_is_absent_when_there_is_no_access_token() {
let key = SigningKey::generate(KID);
let (_, claims) = parts(&proof(&key, "POST", "https://x.example/t", None, None).unwrap());
assert!(claims.get("ath").is_none());
}
#[test]
fn the_nonce_claim_appears_only_when_the_server_supplied_one() {
let key = SigningKey::generate(KID);
let (_, without) = parts(&proof(&key, "POST", "https://x.example/t", None, None).unwrap());
assert!(without.get("nonce").is_none());
let (_, with) =
parts(&proof(&key, "POST", "https://x.example/t", None, Some("srv-nonce")).unwrap());
assert_eq!(with["nonce"], "srv-nonce");
}
#[test]
fn a_proof_verifies_against_its_own_key() {
let key = SigningKey::generate(KID);
let p = proof(&key, "POST", "https://x.example/t", None, None).unwrap();
assert!(verify(&key, &p).is_ok());
assert!(verify(&SigningKey::generate(KID), &p).is_err());
}
#[test]
fn an_unparseable_target_url_is_an_error_not_a_panic() {
let key = SigningKey::generate(KID);
assert!(proof(&key, "GET", "not a url", None, None).is_err());
assert!(proof(&key, "GET", "", None, None).is_err());
}
#[test]
fn a_non_http_scheme_is_rejected() {
let key = SigningKey::generate(KID);
for url in [
"file:///etc/passwd",
"ftp://x.example/a",
"data:text/plain,x",
] {
assert!(
proof(&key, "GET", url, None, None).is_err(),
"allowed {url}"
);
}
}
#[test]
fn a_non_token_method_is_rejected() {
let key = SigningKey::generate(KID);
for method in ["", "gé t", "GET POST", "GET\n", "GE\tT"] {
assert!(
proof(&key, method, "https://x.example/a", None, None).is_err(),
"allowed method {method:?}"
);
}
}
#[test]
fn htu_strips_userinfo() {
let key = SigningKey::generate(KID);
let (_, claims) = parts(
&proof(
&key,
"POST",
"https://Alice:s3cr3t@PDS.Example.COM:443/oauth/token?a=1#f",
None,
None,
)
.unwrap(),
);
let htu = claims["htu"].as_str().unwrap();
assert_eq!(htu, "https://pds.example.com/oauth/token");
assert!(!htu.contains("s3cr3t"), "password leaked into htu: {htu}");
assert!(!htu.contains("Alice"), "username leaked into htu: {htu}");
}
#[test]
fn the_embedded_jwk_is_the_key_that_actually_signed() {
let key = SigningKey::generate(KID);
let p = proof(&key, "POST", "https://x.example/t", None, None).unwrap();
let (header, _) = parts(&p);
let embedded = serde_json::to_string(&header["jwk"]).unwrap();
assert_eq!(
SigningKey::public_thumbprint_of(&embedded).unwrap(),
key.thumbprint().unwrap(),
"the embedded jwk is not the signing key"
);
}
#[test]
fn htm_is_upcased() {
let key = SigningKey::generate(KID);
for (given, want) in [("get", "GET"), ("Post", "POST"), ("delete", "DELETE")] {
let (_, claims) =
parts(&proof(&key, given, "https://x.example/a", None, None).unwrap());
assert_eq!(claims["htm"], want, "for {given}");
}
}
fn rs(www_authenticate: &str, nonce: Option<&str>) -> Option<String> {
nonce_challenge(
Endpoint::ResourceServer,
401,
Some(www_authenticate),
b"",
nonce,
)
}
#[test]
fn the_authorization_server_signals_with_a_400_and_a_json_body() {
assert_eq!(
nonce_challenge(
Endpoint::AuthorizationServer,
400,
None,
br#"{"error":"use_dpop_nonce"}"#,
Some("n1"),
)
.as_deref(),
Some("n1")
);
assert_eq!(
nonce_challenge(
Endpoint::AuthorizationServer,
400,
None,
br#"{"error":"use_dpop_nonce","error_description":"nonce required"}"#,
Some("n1"),
)
.as_deref(),
Some("n1")
);
}
#[test]
fn the_authorization_server_path_ignores_other_errors_and_statuses() {
for (status, body) in [
(400u16, &br#"{"error":"invalid_grant"}"#[..]),
(400, br#"{"error":"invalid_dpop_proof"}"#),
(400, b"not json"),
(400, b""),
(400, br#"{"error":123}"#),
(400, br#"[]"#),
(401, br#"{"error":"use_dpop_nonce"}"#),
(200, br#"{"error":"use_dpop_nonce"}"#),
(500, br#"{"error":"use_dpop_nonce"}"#),
] {
assert!(
nonce_challenge(
Endpoint::AuthorizationServer,
status,
None,
body,
Some("n1")
)
.is_none(),
"acted on status {status} body {:?}",
String::from_utf8_lossy(body)
);
}
}
#[test]
fn the_two_signalling_paths_are_independent() {
assert!(nonce_challenge(
Endpoint::AuthorizationServer,
400,
Some(r#"DPoP error="use_dpop_nonce""#),
br#"{"error":"invalid_grant"}"#,
Some("n1"),
)
.is_none());
assert!(nonce_challenge(
Endpoint::ResourceServer,
401,
None,
br#"{"error":"use_dpop_nonce"}"#,
Some("n1"),
)
.is_none());
}
#[test]
fn the_resource_server_path_requires_a_401() {
for status in [400u16, 403, 200, 500] {
assert!(
nonce_challenge(
Endpoint::ResourceServer,
status,
Some(r#"DPoP error="use_dpop_nonce""#),
b"",
Some("n1"),
)
.is_none(),
"acted on status {status}"
);
}
}
#[test]
fn agrees_with_the_reference_client_on_nonce_detection() {
use Endpoint::{AuthorizationServer as As, ResourceServer as Rs};
type Case = (Endpoint, u16, Option<&'static str>, &'static [u8], bool);
let cases: &[Case] = &[
(As, 400, None, br#"{"error":"use_dpop_nonce"}"#, true),
(
As,
400,
None,
br#"{"error":"use_dpop_nonce","error_description":"x"}"#,
true,
),
(As, 400, None, br#"{"error":"invalid_grant"}"#, false),
(As, 400, None, br#"{"error":"invalid_dpop_proof"}"#, false),
(As, 400, None, b"not json", false),
(As, 400, None, b"", false),
(As, 401, None, br#"{"error":"use_dpop_nonce"}"#, false),
(As, 200, None, br#"{"error":"use_dpop_nonce"}"#, false),
(
As,
400,
Some(r#"DPoP error="use_dpop_nonce""#),
br#"{"error":"invalid_grant"}"#,
false,
),
(Rs, 401, Some(r#"DPoP error="use_dpop_nonce""#), b"", true),
(
Rs,
401,
Some(r#"DPoP algs="ES256", error="use_dpop_nonce""#),
b"",
true,
),
(
Rs,
401,
Some(r#"Bearer error="use_dpop_nonce""#),
b"",
false,
),
(
Rs,
401,
Some(r#"DPoP error="invalid_dpop_proof""#),
b"",
false,
),
(Rs, 400, Some(r#"DPoP error="use_dpop_nonce""#), b"", false),
(Rs, 401, None, br#"{"error":"use_dpop_nonce"}"#, false),
];
for (i, (endpoint, status, header, body, expected)) in cases.iter().enumerate() {
let got = nonce_challenge(*endpoint, *status, *header, body, Some("n1")).is_some();
assert_eq!(
got, *expected,
"case {i} ({endpoint:?}, {status}, {header:?}) disagrees with the reference"
);
}
}
#[test]
fn an_oversized_error_body_is_not_parsed() {
let mut body = br#"{"error":"use_dpop_nonce","pad":""#.to_vec();
body.extend(std::iter::repeat_n(b'a', 32 * 1024));
body.extend(br#""}"#);
assert!(
nonce_challenge(Endpoint::AuthorizationServer, 400, None, &body, Some("n1")).is_none()
);
}
#[test]
fn a_dpop_nonce_challenge_yields_the_nonce_to_retry_with() {
for header in [
r#"DPoP error="use_dpop_nonce", error_description="Authorization server requires nonce in DPoP proof""#,
r#"DPoP algs="ES256", error="use_dpop_nonce""#,
r#"dpop error="use_dpop_nonce""#,
r#"DPoP algs="ES256", error = "use_dpop_nonce""#,
r#"DPoP error = "use_dpop_nonce""#,
] {
assert_eq!(
rs(header, Some("n1")).as_deref(),
Some("n1"),
"should match: {header}"
);
}
}
#[test]
fn challenges_are_matched_to_their_own_scheme() {
assert_eq!(
rs(
r#"Bearer error="invalid_token", DPoP error="use_dpop_nonce", algs="ES256""#,
Some("n1")
)
.as_deref(),
Some("n1")
);
assert!(rs(r#"Bearer error="use_dpop_nonce""#, Some("n1")).is_none());
assert!(rs(
r#"Basic realm="r", Bearer error="use_dpop_nonce""#,
Some("n1")
)
.is_none());
assert!(rs(
r#"DPoP algs="ES256", Bearer error="use_dpop_nonce""#,
Some("n1")
)
.is_none());
}
#[test]
fn invalid_dpop_proof_is_a_failure_not_a_rs() {
assert!(rs(r#"DPoP error="invalid_dpop_proof""#, Some("n1")).is_none());
}
#[test]
fn no_retry_without_a_supplied_nonce() {
assert!(rs(r#"DPoP error="use_dpop_nonce""#, None).is_none());
assert!(rs(r#"DPoP error="use_dpop_nonce""#, Some("")).is_none());
}
#[test]
fn unrelated_challenges_do_not_trigger_a_retry() {
for header in [
r#"Bearer error="invalid_token""#,
r#"DPoP error="invalid_grant""#,
r#"DPoP algs="ES256""#,
"",
"garbage",
r#"DPoP error="x", error_description="do not use_dpop_nonce here""#,
] {
assert!(
rs(header, Some("n1")).is_none(),
"should not match: {header}"
);
}
}
#[test]
fn a_token68_challenge_does_not_discard_the_other_challenges() {
for header in [
r#"DPoP error="use_dpop_nonce", Negotiate YII="#,
r#"Negotiate YII=, DPoP error="use_dpop_nonce""#,
r#"Basic realm=x, Negotiate abc==, DPoP error="use_dpop_nonce""#,
r#"DPoP foo=, error="use_dpop_nonce""#,
r#"DPoP error="use_dpop_nonce", bad="#,
] {
assert_eq!(
rs(header, Some("n1")).as_deref(),
Some("n1"),
"token68 or unreadable param discarded the header: {header}"
);
}
}
#[test]
fn a_token68_challenge_cannot_manufacture_a_rs() {
for header in [
r#"Negotiate YII="#,
r#"Basic realm=x, Negotiate abc=="#,
r#"Bearer error="use_dpop_nonce", Negotiate YII="#,
] {
assert!(
rs(header, Some("n1")).is_none(),
"invented a challenge from: {header}"
);
}
}
#[test]
fn a_malformed_header_does_not_trigger_a_retry() {
for header in [
r#"DPoP error_description="he said "x, junk error="use_dpop_nonce""#,
r#"DPoP realm="r", error_description="unbalanced " here, y error="use_dpop_nonce""#,
r#"DPoP error="use_dpop_nonce"#,
r#"DPoP error=""use_dpop_nonce"""#,
] {
assert!(
rs(header, Some("n1")).is_none(),
"malformed header was acted on: {header}"
);
}
}
#[test]
fn quoted_values_may_contain_commas_and_escaped_quotes() {
assert_eq!(
rs(
r#"DPoP error_description="one, two, three", error="use_dpop_nonce""#,
Some("n1")
)
.as_deref(),
Some("n1")
);
assert_eq!(
rs(
r#"DPoP error_description="he said \"hi\", ok", error="use_dpop_nonce""#,
Some("n1")
)
.as_deref(),
Some("n1")
);
}
#[test]
fn an_unquoted_token_value_is_accepted() {
assert_eq!(
rs(r#"DPoP error=use_dpop_nonce"#, Some("n1")).as_deref(),
Some("n1")
);
}
#[test]
fn a_pathological_header_terminates() {
let big = format!("DPoP error=\"{}", "a,".repeat(20_000));
assert!(rs(&big, Some("n1")).is_none());
let quotes = "\"".repeat(20_000);
assert!(rs("es, Some("n1")).is_none());
}
}