use axess_clock::{Clock, SystemClock};
use axess_rng::SecureRng;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{DateTime, Utc};
use reqwest::header::AUTHORIZATION;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use url::Url;
use zeroize::Zeroizing;
use crate::delegated::error::DelegatedError;
use axess_factors::ZeroizedString;
use super::credential::StoredDelegation;
use super::provider::DelegatedProvider;
#[derive(Debug, Clone)]
pub struct GrantContext {
pub state: String,
pub code_verifier: ZeroizedString,
pub requested_scopes: Vec<String>,
}
pub fn begin_grant(
provider: &DelegatedProvider,
scopes_override: &[String],
rng: &dyn SecureRng,
) -> Result<(Url, GrantContext), DelegatedError> {
let verifier = random_token(rng);
let state = random_token(rng);
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
let scopes: Vec<String> = if scopes_override.is_empty() {
provider.default_scopes.clone()
} else {
scopes_override.to_vec()
};
let scope_str = scopes.join(" ");
let mut auth_url = provider.authorization_endpoint.clone();
{
let mut q = auth_url.query_pairs_mut();
q.append_pair("response_type", "code");
q.append_pair("client_id", &provider.client_id);
q.append_pair("redirect_uri", provider.redirect_uri.as_str());
q.append_pair("state", &state);
q.append_pair("code_challenge", &challenge);
q.append_pair("code_challenge_method", "S256");
if !scope_str.is_empty() {
q.append_pair("scope", &scope_str);
}
}
let context = GrantContext {
state: (*state).clone(),
code_verifier: ZeroizedString::from((*verifier).clone()),
requested_scopes: scopes,
};
Ok((auth_url, context))
}
fn random_token(rng: &dyn SecureRng) -> Zeroizing<String> {
let mut raw = Zeroizing::new([0u8; 32]);
rng.fill_bytes(&mut *raw);
Zeroizing::new(URL_SAFE_NO_PAD.encode(*raw))
}
pub async fn complete_grant(
provider: &DelegatedProvider,
context: &GrantContext,
code: &str,
state_from_callback: &str,
http: &reqwest::Client,
) -> Result<StoredDelegation, DelegatedError> {
complete_grant_at(
provider,
context,
code,
state_from_callback,
http,
SystemClock.now(),
)
.await
}
pub async fn complete_grant_at(
provider: &DelegatedProvider,
context: &GrantContext,
code: &str,
state_from_callback: &str,
http: &reqwest::Client,
now: DateTime<Utc>,
) -> Result<StoredDelegation, DelegatedError> {
let state_matches: bool = context
.state
.as_bytes()
.ct_eq(state_from_callback.as_bytes())
.into();
if !state_matches {
return Err(DelegatedError::StateMismatch);
}
if !axess_factors::pkce::is_valid_verifier(&context.code_verifier) {
return Err(DelegatedError::PkceVerifier);
}
let redirect_uri = provider.redirect_uri.as_str();
let form: Vec<(&str, &str)> = vec![
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &context.code_verifier),
("client_id", &provider.client_id),
];
let response = post_token_endpoint(http, provider, &form).await?;
parse_token_response(provider, response, now).await
}
pub(super) async fn refresh_token_grant(
provider: &DelegatedProvider,
refresh_token: &str,
http: &reqwest::Client,
clock: &dyn Clock,
) -> Result<StoredDelegation, DelegatedError> {
let form: Vec<(&str, &str)> = vec![
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("client_id", &provider.client_id),
];
let response = post_token_endpoint(http, provider, &form).await?;
parse_token_response(provider, response, clock.now()).await
}
async fn post_token_endpoint(
http: &reqwest::Client,
provider: &DelegatedProvider,
form: &[(&str, &str)],
) -> Result<reqwest::Response, DelegatedError> {
let encoded = axess_factors::basic_credentials(&provider.client_id, &provider.client_secret);
http.post(provider.token_endpoint.clone())
.header(AUTHORIZATION, format!("Basic {encoded}"))
.form(form)
.send()
.await
.map_err(|e| DelegatedError::Transport(e.to_string()))
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: ZeroizedString,
#[serde(default)]
refresh_token: Option<ZeroizedString>,
#[serde(default)]
expires_in: Option<u64>,
#[serde(default)]
token_type: Option<String>,
#[serde(default)]
scope: Option<String>,
}
async fn parse_token_response(
provider: &DelegatedProvider,
response: reqwest::Response,
now: DateTime<Utc>,
) -> Result<StoredDelegation, DelegatedError> {
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
if status.as_u16() == 400 && body.contains("invalid_grant") {
return Err(DelegatedError::RefreshRejected);
}
return Err(DelegatedError::TokenEndpoint {
status: status.as_u16(),
body,
});
}
let parsed: TokenResponse = response
.json()
.await
.map_err(|e| DelegatedError::MalformedResponse(e.to_string()))?;
if parsed.access_token.is_empty() {
return Err(DelegatedError::MalformedResponse(
"access_token field is empty".into(),
));
}
let expires_at = match parsed.expires_in.filter(|s| *s > 0) {
None => None,
Some(s) => Some(axess_factors::expiry_after(now, s).ok_or_else(|| {
DelegatedError::MalformedResponse(format!("expires_in {s} is out of range"))
})?),
};
let scopes: Vec<String> = parsed
.scope
.map(|s| s.split_whitespace().map(str::to_string).collect())
.unwrap_or_default();
Ok(StoredDelegation {
provider: provider.name.clone(),
access_token: parsed.access_token,
refresh_token: parsed.refresh_token,
expires_at,
scopes,
token_type: parsed.token_type.unwrap_or_else(|| "Bearer".to_string()),
})
}