mod challenge;
use std::sync::Arc;
use bon::Builder;
pub use challenge::{Challenge, ChallengePayload, parse_challenges};
use http::{HeaderMap, HeaderName, Method, Uri, header::AUTHORIZATION};
use crate::{
cache::TokenCache,
core::{Error, ErrorKind},
grant::core::TokenResponse,
token::AccessToken,
};
#[derive(Builder)]
pub struct HttpAuthorizer {
#[builder(with = |cache: impl TokenCache + 'static| Arc::new(cache) as Arc<dyn TokenCache>)]
cache: Arc<dyn TokenCache>,
#[builder(default = AUTHORIZATION)]
authorization_header: HeaderName,
}
impl core::fmt::Debug for HttpAuthorizer {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("HttpAuthorizer")
.field("authorization_header", &self.authorization_header)
.finish_non_exhaustive()
}
}
impl HttpAuthorizer {
pub async fn get_headers(&self, method: &Method, uri: &Uri) -> Result<HeaderMap, Error> {
let token = self.cache.get_token_response().await?;
let mut headers = HeaderMap::new();
match token.access_token() {
AccessToken::Dpop(dpop_access_token) => {
let Some(proof) = self
.cache
.resource_server_dpop()
.proof(
method,
uri,
dpop_access_token.token(),
dpop_access_token.jkt(),
)
.await?
else {
return Err(Error::from(ErrorKind::Dpop)
.with_context("received DPoP token but no DPoP configuration present"));
};
headers.insert(
"DPoP",
proof.expose_secret().parse().map_err(|source| {
Error::new(ErrorKind::Dpop, source)
.with_context("DPoP proof is not a valid header value")
})?,
);
headers.insert(
&self.authorization_header,
dpop_access_token.expose_header_value().map_err(|source| {
Error::new(ErrorKind::Protocol, source)
.with_context("access token is not a valid header value")
})?,
);
}
AccessToken::Bearer(bearer_access_token) => {
headers.insert(
&self.authorization_header,
bearer_access_token
.expose_header_value()
.map_err(|source| {
Error::new(ErrorKind::Protocol, source)
.with_context("access token is not a valid header value")
})?,
);
}
}
Ok(headers)
}
pub fn cache(&self) -> &dyn TokenCache {
self.cache.as_ref()
}
pub async fn prime(&self, response: Arc<TokenResponse>) -> Result<(), Error> {
self.cache.prime(response).await
}
pub fn invalidate(&self) {
self.cache.invalidate();
}
pub fn set_nonce(&self, uri: &Uri, nonce: String) {
self.cache.resource_server_dpop().update_nonce(uri, nonce);
}
pub fn process_response(&self, uri: &Uri, headers: &HeaderMap) {
if let Some(nonce) = extract_dpop_nonce(headers) {
self.set_nonce(uri, nonce);
}
if challenge::challenge_has_error(headers, "invalid_token") {
self.invalidate();
}
}
}
#[must_use]
pub fn extract_dpop_nonce(headers: &HeaderMap) -> Option<String> {
headers
.get("DPoP-Nonce")
.and_then(|v| v.to_str().ok())
.map(std::borrow::ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use super::*;
use crate::{
core::{dpop::NoDPoP, platform::MaybeSendBoxFuture},
grant::core::TokenResponse,
};
#[derive(Clone, Default)]
struct FakeCache {
invalidated: Arc<AtomicBool>,
}
impl TokenCache for FakeCache {
fn get_token_response(&self) -> MaybeSendBoxFuture<'_, Result<Arc<TokenResponse>, Error>> {
Box::pin(async { Err(Error::from(ErrorKind::Config)) })
}
fn resource_server_dpop(&self) -> &dyn crate::core::dpop::ResourceServerDPoP {
&NoDPoP
}
fn prime(
&self,
_response: Arc<TokenResponse>,
) -> MaybeSendBoxFuture<'_, Result<(), Error>> {
Box::pin(async { Ok(()) })
}
fn invalidate(&self) {
self.invalidated.store(true, Ordering::Relaxed);
}
}
fn authorizer() -> (HttpAuthorizer, Arc<AtomicBool>) {
let cache = FakeCache::default();
let invalidated = cache.invalidated.clone();
(HttpAuthorizer::builder().cache(cache).build(), invalidated)
}
fn uri() -> Uri {
"https://api.example.com/resource".parse().unwrap()
}
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
let mut headers = HeaderMap::new();
for (name, value) in pairs {
headers.append(
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
value.parse().unwrap(),
);
}
headers
}
#[test]
fn invalid_token_challenge_invalidates() {
let (authorizer, invalidated) = authorizer();
let headers = headers(&[(
"www-authenticate",
r#"Bearer error="invalid_token", error_description="The access token expired""#,
)]);
authorizer.process_response(&uri(), &headers);
assert!(invalidated.load(Ordering::Relaxed));
}
#[test]
fn unquoted_error_param_is_recognized() {
let (authorizer, invalidated) = authorizer();
let headers = headers(&[("www-authenticate", "Bearer error=invalid_token")]);
authorizer.process_response(&uri(), &headers);
assert!(invalidated.load(Ordering::Relaxed));
}
#[test]
fn dpop_nonce_challenge_does_not_invalidate() {
let (authorizer, invalidated) = authorizer();
let headers = headers(&[
(
"www-authenticate",
r#"DPoP error="use_dpop_nonce", error_description="Resource server requires nonce in DPoP proof""#,
),
("dpop-nonce", "eyJ7S_zG.eyJH0-Z.HX4w-7v"),
]);
authorizer.process_response(&uri(), &headers);
assert!(!invalidated.load(Ordering::Relaxed));
}
#[test]
fn other_challenges_do_not_invalidate() {
let (authorizer, invalidated) = authorizer();
let headers = headers(&[(
"www-authenticate",
r#"Bearer error="insufficient_scope", scope="read write""#,
)]);
authorizer.process_response(&uri(), &headers);
assert!(!invalidated.load(Ordering::Relaxed));
}
#[test]
fn challenge_free_response_does_not_invalidate() {
let (authorizer, invalidated) = authorizer();
authorizer.process_response(&uri(), &headers(&[("dpop-nonce", "rotated")]));
authorizer.process_response(&uri(), &headers(&[]));
assert!(!invalidated.load(Ordering::Relaxed));
}
#[test]
fn error_code_must_match_exactly() {
for value in [
r#"Bearer error="invalid_token_format""#,
r#"Bearer error="invalid_request", error_uri="https://as.example.com/doc?error=invalid_token""#,
] {
let (authorizer, invalidated) = authorizer();
authorizer.process_response(&uri(), &headers(&[("www-authenticate", value)]));
assert!(
!invalidated.load(Ordering::Relaxed),
"must not invalidate for: {value}"
);
}
}
#[test]
fn error_inside_quoted_description_does_not_invalidate() {
let (authorizer, invalidated) = authorizer();
let headers = headers(&[(
"www-authenticate",
r#"Bearer error="invalid_request", error_description="try again, error=invalid_token happens sometimes""#,
)]);
authorizer.process_response(&uri(), &headers);
assert!(!invalidated.load(Ordering::Relaxed));
}
}