use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use base64::Engine;
use better_auth_core::{AuthError, AuthResult};
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
const CLIENT_ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenGrantType {
AuthorizationCode,
RefreshToken,
}
impl TokenGrantType {
pub fn as_str(self) -> &'static str {
match self {
Self::AuthorizationCode => "authorization_code",
Self::RefreshToken => "refresh_token",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct ClientAssertionContext<'a> {
pub client_id: &'a str,
pub token_endpoint: &'a str,
pub grant_type: TokenGrantType,
}
#[async_trait]
pub trait ClientAssertion: Send + Sync {
async fn get_client_assertion(&self, context: ClientAssertionContext<'_>)
-> AuthResult<String>;
}
pub struct TokenEndpointRequestContext<'a> {
pub body: &'a mut Vec<(String, String)>,
pub headers: &'a mut HeaderMap,
pub client_id: &'a str,
pub client_secret: Option<&'a str>,
pub token_endpoint: &'a str,
pub grant_type: TokenGrantType,
}
#[async_trait]
pub trait TokenRequestHook: Send + Sync {
async fn customize_request(&self, context: TokenEndpointRequestContext<'_>) -> AuthResult<()>;
}
#[derive(Clone)]
pub enum TokenEndpointAuth {
ClientSecretPost,
ClientSecretBasic,
None,
PrivateKeyJwt(Arc<dyn ClientAssertion>),
Custom(Arc<dyn TokenRequestHook>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenEndpointSecretAuthentication {
Basic,
Post,
}
pub(super) struct TokenAuthentication<'a> {
pub client_id: &'a str,
pub client_secret: Option<&'a str>,
pub token_endpoint: &'a str,
pub grant_type: TokenGrantType,
pub token_endpoint_auth: Option<&'a TokenEndpointAuth>,
pub authentication: Option<TokenEndpointSecretAuthentication>,
}
pub(super) struct AuthorizationCodeRequest<'a> {
pub code: &'a str,
pub redirect_uri: &'a str,
pub code_verifier: Option<&'a str>,
pub client_key: Option<&'a str>,
pub device_id: Option<&'a str>,
pub headers: &'a HeaderMap,
pub additional_params: &'a HashMap<String, String>,
pub resources: &'a [String],
}
pub(super) struct TokenRequest {
pub body: Vec<(String, String)>,
pub headers: HeaderMap,
}
impl TokenRequest {
fn new(grant_type: TokenGrantType) -> Self {
let mut headers = HeaderMap::new();
let _ = headers.insert(
CONTENT_TYPE,
HeaderValue::from_static("application/x-www-form-urlencoded"),
);
let _ = headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
Self {
body: vec![("grant_type".to_string(), grant_type.as_str().to_string())],
headers,
}
}
pub(super) fn authorization_code(input: AuthorizationCodeRequest<'_>) -> Self {
let mut request = Self::new(TokenGrantType::AuthorizationCode);
request.headers.extend(input.headers.clone());
request.set("code", input.code);
for (key, value) in [
("code_verifier", input.code_verifier),
("client_key", input.client_key),
("device_id", input.device_id),
] {
if let Some(value) = value.filter(|value| !value.is_empty()) {
request.set(key, value);
}
}
request.set("redirect_uri", input.redirect_uri);
request.add_resources(input.resources);
for (key, value) in input.additional_params {
if !request.has(key) {
request.set(key, value);
}
}
request
}
pub(super) fn refresh_token(
refresh_token: &str,
extra_params: &HashMap<String, String>,
resources: &[String],
) -> Self {
let mut request = Self::new(TokenGrantType::RefreshToken);
request.set("refresh_token", refresh_token);
request.add_resources(resources);
for (key, value) in extra_params {
if !matches!(
key.as_str(),
"grant_type" | "refresh_token" | "__proto__" | "constructor" | "prototype"
) {
request.set(key, value);
}
}
request
}
pub(super) async fn authenticate(&mut self, input: TokenAuthentication<'_>) -> AuthResult<()> {
self.assert_complete_assertion()?;
if self.has("client_assertion") {
if input.token_endpoint_auth.is_some() {
return Err(AuthError::internal(
"client_assertion body parameters cannot be combined with tokenEndpointAuth",
));
}
self.assert_no_client_secret("private_key_jwt", input.client_secret)?;
if !input.client_id.is_empty() {
self.set("client_id", input.client_id);
}
return Ok(());
}
let default_auth = match input.authentication {
Some(TokenEndpointSecretAuthentication::Basic) => TokenEndpointAuth::ClientSecretBasic,
_ if input.client_secret.is_some_and(|secret| !secret.is_empty()) => {
TokenEndpointAuth::ClientSecretPost
}
_ => TokenEndpointAuth::None,
};
match input.token_endpoint_auth.unwrap_or(&default_auth) {
TokenEndpointAuth::Custom(hook) => {
hook.customize_request(TokenEndpointRequestContext {
body: &mut self.body,
headers: &mut self.headers,
client_id: input.client_id,
client_secret: input.client_secret,
token_endpoint: input.token_endpoint,
grant_type: input.grant_type,
})
.await?;
self.assert_complete_assertion()?;
}
TokenEndpointAuth::PrivateKeyJwt(assertion) => {
self.assert_no_client_secret("private_key_jwt", input.client_secret)?;
require_client_id("private_key_jwt", input.client_id)?;
if input.token_endpoint.is_empty() {
return Err(AuthError::internal(
"private_key_jwt token endpoint authentication requires tokenEndpoint",
));
}
let assertion = assertion
.get_client_assertion(ClientAssertionContext {
client_id: input.client_id,
token_endpoint: input.token_endpoint,
grant_type: input.grant_type,
})
.await?;
self.set("client_id", input.client_id);
self.set("client_assertion", &assertion);
self.set("client_assertion_type", CLIENT_ASSERTION_TYPE);
}
TokenEndpointAuth::None => {
self.assert_no_client_secret("none", input.client_secret)?;
require_client_id("none", input.client_id)?;
self.set("client_id", input.client_id);
}
TokenEndpointAuth::ClientSecretBasic => {
if self.has("client_secret") {
return Err(AuthError::internal(
"client_secret_basic token endpoint authentication cannot be combined with client_secret body parameters",
));
}
let secret = require_client_secret("client_secret_basic", input.client_secret)?;
require_client_id("client_secret_basic", input.client_id)?;
let credentials =
format!("{}:{}", form_encode(input.client_id), form_encode(secret));
let authorization = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(credentials)
);
let _ = self.headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&authorization).map_err(|error| {
AuthError::internal(format!("Invalid client authorization header: {error}"))
})?,
);
}
TokenEndpointAuth::ClientSecretPost => {
let secret = require_client_secret("client_secret_post", input.client_secret)?;
require_client_id("client_secret_post", input.client_id)?;
self.set("client_id", input.client_id);
self.set("client_secret", secret);
}
}
Ok(())
}
pub(super) async fn send(self, token_endpoint: &str) -> AuthResult<serde_json::Value> {
send_request(
client()?
.post(token_endpoint)
.headers(self.headers)
.form(&self.body),
token_endpoint,
)
.await
}
pub(super) async fn send_query(
token_endpoint: &str,
params: &[(&str, &str)],
) -> AuthResult<serde_json::Value> {
send_request(client()?.get(token_endpoint).query(params), token_endpoint).await
}
fn has(&self, name: &str) -> bool {
self.body.iter().any(|(key, _)| key == name)
}
fn set(&mut self, name: &str, value: &str) {
let mut updated = false;
self.body.retain_mut(|(key, current)| {
if key != name {
return true;
}
if updated {
return false;
}
*current = value.to_string();
updated = true;
true
});
if !updated {
self.body.push((name.to_string(), value.to_string()));
}
}
fn add_resources(&mut self, resources: &[String]) {
self.body.extend(
resources
.iter()
.map(|value| ("resource".to_string(), value.clone())),
);
}
fn assert_complete_assertion(&self) -> AuthResult<()> {
if self.has("client_assertion") != self.has("client_assertion_type") {
return Err(AuthError::internal(
"client_assertion and client_assertion_type must both be provided",
));
}
Ok(())
}
fn assert_no_client_secret(&self, method: &str, client_secret: Option<&str>) -> AuthResult<()> {
if client_secret.is_some_and(|secret| !secret.is_empty()) || self.has("client_secret") {
return Err(AuthError::internal(format!(
"{method} token endpoint authentication cannot be combined with clientSecret"
)));
}
Ok(())
}
}
fn require_client_id(method: &str, client_id: &str) -> AuthResult<()> {
if client_id.is_empty() {
return Err(AuthError::internal(format!(
"{method} token endpoint authentication requires clientId"
)));
}
Ok(())
}
fn require_client_secret<'a>(method: &str, client_secret: Option<&'a str>) -> AuthResult<&'a str> {
client_secret
.filter(|secret| !secret.is_empty())
.ok_or_else(|| {
AuthError::internal(format!(
"{method} token endpoint authentication requires clientSecret"
))
})
}
fn form_encode(value: &str) -> String {
url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
}
fn client() -> AuthResult<reqwest::Client> {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| AuthError::internal(format!("OAuth HTTP client failed: {error}")))
}
async fn send_request(
request: reqwest::RequestBuilder,
token_endpoint: &str,
) -> AuthResult<serde_json::Value> {
let response = request.send().await.map_err(|error| {
AuthError::internal(format!("Token request failed: {}", error.without_url()))
})?;
if matches!(response.status().as_u16(), 301 | 302 | 303 | 307 | 308) {
return Err(AuthError::internal(format!(
"The OAuth endpoint \"{token_endpoint}\" returned an HTTP redirect. Server-side OAuth fetches refuse redirects to prevent SSRF; configure the final endpoint URL."
)));
}
let response = response.error_for_status().map_err(|error| {
AuthError::internal(format!("Token request failed: {}", error.without_url()))
})?;
response.json().await.map_err(|error| {
AuthError::internal(format!(
"Failed to parse token response: {}",
error.without_url()
))
})
}
#[cfg(test)]
#[path = "token_tests.rs"]
mod tests;