use base64::Engine as _;
use bytes::Bytes;
use cratefield_core::{HttpClient, HttpError};
use http::{HeaderMap, Method, Request, header};
use serde::Deserialize;
use thiserror::Error;
use crate::config::{ClientAuth, ProviderConfig, form_encode};
#[derive(Debug, Error)]
pub enum OAuthError {
#[error("transport: {0}")]
Transport(#[from] HttpError),
#[error(transparent)]
Provider(#[from] Box<ProviderError>),
#[error("unexpected token response: {0}")]
Decode(String),
#[error("oauth client is misconfigured: {0}")]
Config(String),
#[error("no revocation endpoint is configured for this provider")]
NoRevokeEndpoint,
}
impl OAuthError {
#[must_use]
pub fn is_invalid_grant(&self) -> bool {
matches!(self, OAuthError::Provider(error) if error.code == "invalid_grant")
}
}
#[derive(Error)]
#[error("provider answered {status} ({code}): {description}")]
pub struct ProviderError {
pub status: u16,
pub code: String,
pub description: String,
pub body: String,
pub headers: HeaderMap,
}
impl std::fmt::Debug for ProviderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProviderError")
.field("status", &self.status)
.field("code", &self.code)
.field("description", &self.description)
.field(
"body",
&format_args!("<redacted {} bytes>", self.body.len()),
)
.field("headers", &self.headers)
.finish()
}
}
#[derive(Clone, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: Option<String>,
#[serde(default, deserialize_with = "de_expiry")]
pub expires_in: i64,
pub refresh_token: Option<String>,
#[serde(default, deserialize_with = "de_expiry_opt")]
pub refresh_token_expires_in: Option<i64>,
#[serde(default)]
pub scope: String,
}
impl std::fmt::Debug for TokenResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenResponse")
.field("access_token", &"<redacted>")
.field("token_type", &self.token_type)
.field("expires_in", &self.expires_in)
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "<redacted>"),
)
.field("refresh_token_expires_in", &self.refresh_token_expires_in)
.field("scope", &self.scope)
.finish()
}
}
fn de_expiry<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<i64, D::Error> {
Ok(de_expiry_opt(deserializer)?.unwrap_or(0))
}
fn de_expiry_opt<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<i64>, D::Error> {
deserializer.deserialize_any(Expiry)
}
struct Expiry;
impl serde::de::Visitor<'_> for Expiry {
type Value = Option<i64>;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("an integer or a numeric string of seconds")
}
fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_i64<E: serde::de::Error>(self, seconds: i64) -> Result<Self::Value, E> {
Ok(Some(seconds))
}
fn visit_u64<E: serde::de::Error>(self, seconds: u64) -> Result<Self::Value, E> {
i64::try_from(seconds)
.map(Some)
.map_err(serde::de::Error::custom)
}
fn visit_str<E: serde::de::Error>(self, seconds: &str) -> Result<Self::Value, E> {
seconds
.trim()
.parse::<i64>()
.map(Some)
.map_err(serde::de::Error::custom)
}
}
impl TokenResponse {
#[must_use]
pub fn rotated_refresh_token<'a>(&'a self, previous: &'a str) -> &'a str {
self.refresh_token.as_deref().unwrap_or(previous)
}
}
pub struct OAuthClient<'a> {
http: &'a dyn HttpClient,
config: &'a ProviderConfig,
}
impl<'a> OAuthClient<'a> {
#[must_use]
pub fn new(http: &'a dyn HttpClient, config: &'a ProviderConfig) -> Self {
Self { http, config }
}
pub async fn exchange_code(
&self,
code: &str,
redirect_uri: &str,
pkce_verifier: Option<&str>,
) -> Result<TokenResponse, OAuthError> {
let mut form: Vec<(&str, String)> = vec![
("grant_type", "authorization_code".to_owned()),
("code", code.to_owned()),
("redirect_uri", redirect_uri.to_owned()),
];
if let Some(verifier) = pkce_verifier {
form.push(("code_verifier", verifier.to_owned()));
}
self.token_call(form).await
}
pub async fn refresh(&self, refresh_token: &str) -> Result<TokenResponse, OAuthError> {
self.token_call(vec![
("grant_type", "refresh_token".to_owned()),
("refresh_token", refresh_token.to_owned()),
])
.await
}
pub async fn revoke(&self, token: &str, token_type_hint: &str) -> Result<(), OAuthError> {
let Some(url) = self.config.revoke_url.as_deref() else {
return Err(OAuthError::NoRevokeEndpoint);
};
let form = self.form(vec![
("token", token.to_owned()),
("token_type_hint", token_type_hint.to_owned()),
])?;
let mut builder = Request::builder()
.method(Method::POST)
.uri(url)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
if let Some(basic) = self.basic_header()? {
builder = builder.header(header::AUTHORIZATION, basic);
}
let request = builder
.body(Bytes::from(form))
.map_err(|err| OAuthError::Transport(HttpError::Transport(err.to_string())))?;
let response = self.http.send(request).await?;
if response.status().is_success() {
return Ok(());
}
Err(provider_error(&response))
}
fn form(&self, mut params: Vec<(&str, String)>) -> Result<String, OAuthError> {
if self.config.client_auth == ClientAuth::ClientSecretPost {
params.push(("client_id", self.config.client_id.clone()));
params.push(("client_secret", self.secret()?.to_owned()));
}
Ok(params
.iter()
.map(|(name, value)| format!("{name}={}", form_encode(value)))
.collect::<Vec<_>>()
.join("&"))
}
fn basic_header(&self) -> Result<Option<String>, OAuthError> {
if self.config.client_auth != ClientAuth::ClientSecretBasic {
return Ok(None);
}
let credentials = format!(
"{}:{}",
form_encode(&self.config.client_id),
form_encode(self.secret()?)
);
Ok(Some(format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(credentials)
)))
}
fn secret(&self) -> Result<&str, OAuthError> {
self.config.client_secret.as_deref().ok_or_else(|| {
OAuthError::Config("the configured client auth needs a client_secret".to_owned())
})
}
async fn token_call(&self, form: Vec<(&str, String)>) -> Result<TokenResponse, OAuthError> {
let body = self.form(form)?;
let mut request = Request::builder()
.method(Method::POST)
.uri(&self.config.token_url)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::ACCEPT, "application/json");
if let Some(basic) = self.basic_header()? {
request = request.header(header::AUTHORIZATION, basic);
}
let request = request
.body(Bytes::from(body))
.map_err(|err| OAuthError::Transport(HttpError::Transport(err.to_string())))?;
let response = self.http.send(request).await?;
if !response.status().is_success() {
return Err(provider_error(&response));
}
serde_json::from_slice(response.body())
.map_err(|err| OAuthError::Decode(format!("token response did not parse: {err}")))
}
}
fn provider_error(response: &http::Response<Bytes>) -> OAuthError {
let status = response.status();
let raw = String::from_utf8_lossy(response.body()).into_owned();
let parsed = serde_json::from_slice::<ErrorBody>(response.body()).ok();
let code = parsed
.as_ref()
.and_then(|body| body.error.clone())
.unwrap_or_default();
let description = parsed
.and_then(|body| body.error_description)
.unwrap_or_default();
OAuthError::from(Box::new(ProviderError {
status: status.as_u16(),
code,
description,
body: raw,
headers: response.headers().clone(),
}))
}
#[derive(Deserialize)]
struct ErrorBody {
error: Option<String>,
error_description: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use std::sync::RwLock;
const TOKENS: &str = r#"{"access_token":"at-critical-do-not-log","token_type":"Bearer",
"expires_in":3600,"refresh_token":"rt-critical-do-not-log",
"refresh_token_expires_in":5184000,"scope":"w_member_social"}"#;
struct Scripted {
status: u16,
body: &'static str,
seen: RwLock<Vec<Request<Bytes>>>,
}
impl Scripted {
fn ok() -> Self {
Self {
status: 200,
body: TOKENS,
seen: RwLock::new(Vec::new()),
}
}
fn with(status: u16, body: &'static str) -> Self {
Self {
status,
body,
seen: RwLock::new(Vec::new()),
}
}
fn form_of(&self, index: usize) -> String {
let seen = self.seen.read().expect("lock");
String::from_utf8_lossy(seen[index].body()).into_owned()
}
fn header_of(&self, index: usize, name: &str) -> Option<String> {
let seen = self.seen.read().expect("lock");
seen[index]
.headers()
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::to_owned)
}
}
#[async_trait]
impl HttpClient for Scripted {
async fn send(&self, request: Request<Bytes>) -> Result<http::Response<Bytes>, HttpError> {
self.seen.write().expect("lock").push(request);
Ok(http::Response::builder()
.status(self.status)
.body(Bytes::from(self.body))
.expect("static body"))
}
}
fn post_config() -> ProviderConfig {
ProviderConfig {
authorize_url: "https://provider.example/oauth/authorize".to_owned(),
token_url: "https://provider.example/oauth/token".to_owned(),
revoke_url: Some("https://provider.example/oauth/revoke".to_owned()),
client_id: "client".to_owned(),
client_secret: Some("hunter2-do-not-log".to_owned()),
scopes: Vec::new(),
scope_separator: " ",
client_auth: ClientAuth::ClientSecretPost,
}
}
fn basic_config() -> ProviderConfig {
ProviderConfig {
client_auth: ClientAuth::ClientSecretBasic,
..post_config()
}
}
#[pollster::test]
async fn exchange_sends_the_grant_with_the_credentials_in_the_body() {
let http = Scripted::ok();
let tokens = OAuthClient::new(&http, &post_config())
.exchange_code("the-code", "https://api.test/cb", Some("the-verifier"))
.await
.expect("the exchange succeeds");
assert_eq!(tokens.access_token, "at-critical-do-not-log");
assert_eq!(tokens.expires_in, 3600);
assert_eq!(tokens.refresh_token_expires_in, Some(5_184_000));
assert_eq!(
tokens.rotated_refresh_token("older"),
"rt-critical-do-not-log"
);
let form = http.form_of(0);
for piece in [
"grant_type=authorization_code",
"code=the-code",
"redirect_uri=https%3A%2F%2Fapi.test%2Fcb",
"code_verifier=the-verifier",
"client_id=client",
"client_secret=hunter2-do-not-log",
] {
assert!(form.contains(piece), "{piece:?} missing from {form}");
}
assert_eq!(
http.header_of(0, "authorization"),
None,
"Post auth: no header"
);
}
#[pollster::test]
async fn basic_auth_keeps_the_client_id_out_of_the_body() {
let http = Scripted::ok();
OAuthClient::new(&http, &basic_config())
.refresh("rt-being-rotated")
.await
.expect("the refresh succeeds");
let form = http.form_of(0);
assert!(form.contains("grant_type=refresh_token"), "{form}");
assert!(form.contains("refresh_token=rt-being-rotated"), "{form}");
assert!(!form.contains("client_id"), "{form}");
assert!(!form.contains("client_secret"), "{form}");
let authorization = http.header_of(0, "authorization").expect("Basic header");
assert_eq!(
authorization, "Basic Y2xpZW50Omh1bnRlcjItZG8tbm90LWxvZw==",
"form-encoded credentials, standard base64"
);
}
#[pollster::test]
async fn quoted_expiries_parse_like_numbers() {
let http = Scripted::with(
200,
r#"{"access_token":"at","expires_in":"3600",
"refresh_token_expires_in":null,"scope":"s"}"#,
);
let tokens = OAuthClient::new(&http, &post_config())
.refresh("rt")
.await
.expect("parses");
assert_eq!(tokens.expires_in, 3600);
assert_eq!(tokens.refresh_token_expires_in, None);
assert_eq!(tokens.rotated_refresh_token("kept"), "kept");
}
#[pollster::test]
async fn a_provider_error_keeps_the_prose_and_hides_the_body() {
let http = Scripted::with(
400,
r#"{"error":"invalid_grant","error_description":"Refresh token rt-critical-do-not-log expired"}"#,
);
let error = OAuthClient::new(&http, &post_config())
.refresh("rt-critical-do-not-log")
.await
.expect_err("the provider refused");
assert!(error.is_invalid_grant(), "{error}");
assert!(
error
.to_string()
.contains("Refresh token rt-critical-do-not-log expired"),
"{error}"
);
let debug = format!("{error:?}");
assert!(debug.contains("invalid_grant"), "{debug}");
assert!(
!debug.contains("\"error_description\""),
"raw body in the debug: {debug}"
);
assert!(debug.contains("<redacted 92 bytes>"), "{debug}");
let http = Scripted::with(400, "not json at all");
let error = OAuthClient::new(&http, &post_config())
.refresh("rt")
.await
.expect_err("a non-JSON refusal is still a provider error");
assert!(
error.to_string().starts_with("provider answered 400"),
"{error}"
);
assert!(matches!(error, OAuthError::Provider(_)), "{error:?}");
}
#[pollster::test]
async fn a_success_that_is_not_json_is_a_decode_error() {
let http = Scripted::with(200, "<html>ok</html>");
let error = OAuthClient::new(&http, &post_config())
.refresh("rt")
.await
.expect_err("html is not a token response");
assert!(matches!(error, OAuthError::Decode(_)), "{error}");
}
#[pollster::test]
async fn the_debug_of_a_token_response_never_shows_the_tokens() {
let http = Scripted::ok();
let tokens = OAuthClient::new(&http, &post_config())
.refresh("rt")
.await
.expect("parses");
let debug = format!("{tokens:?}");
assert!(!debug.contains("at-critical-do-not-log"), "{debug}");
assert!(!debug.contains("rt-critical-do-not-log"), "{debug}");
assert_eq!(debug.matches("<redacted>").count(), 2, "{debug}");
}
#[pollster::test]
async fn revocation_refuses_to_guess_an_endpoint() {
let http = Scripted::ok();
let config = ProviderConfig {
revoke_url: None,
..post_config()
};
let error = OAuthClient::new(&http, &config)
.revoke("rt", "refresh_token")
.await
.expect_err("no revoke endpoint configured");
assert!(matches!(error, OAuthError::NoRevokeEndpoint), "{error}");
}
}