231 lines
7.1 KiB
Rust
231 lines
7.1 KiB
Rust
//! OAuth2 refresh exchange, endpoint policy, and a revision-aware process-local cache.
|
|
use super::{
|
|
items::{Item, rule_matches},
|
|
types::FieldType,
|
|
};
|
|
use std::{
|
|
collections::{BTreeMap, BTreeSet},
|
|
sync::Mutex,
|
|
time::{Duration, Instant},
|
|
};
|
|
use zeroize::Zeroizing;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Error;
|
|
impl std::fmt::Display for Error {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str("OAuth refresh failed or returned an invalid response")
|
|
}
|
|
}
|
|
impl std::error::Error for Error {}
|
|
|
|
/// A single HTTP exchange. Adapters must honor the deadline and never follow redirects.
|
|
pub trait Http: Sync {
|
|
fn send_once(
|
|
&self,
|
|
request: &Request,
|
|
deadline: Instant,
|
|
) -> impl std::future::Future<Output = Result<Response, Error>> + Send;
|
|
}
|
|
/// The status and body of one response, without any redirect exchange.
|
|
pub struct Response {
|
|
pub status: http::StatusCode,
|
|
pub body: Zeroizing<Vec<u8>>,
|
|
}
|
|
/// Contains credentials. Deliberately does not implement Debug or Serialize.
|
|
pub struct Request {
|
|
pub url: String,
|
|
pub method: &'static str,
|
|
pub headers: Vec<(&'static str, String)>,
|
|
pub body: Zeroizing<String>,
|
|
}
|
|
pub fn request(
|
|
token_url: &str,
|
|
refresh_token: &str,
|
|
client_id: &str,
|
|
client_secret: &str,
|
|
) -> Result<Request, Error> {
|
|
let target = url::Url::parse(token_url).map_err(|_| Error)?;
|
|
if target.scheme() != "https"
|
|
|| !target.username().is_empty()
|
|
|| target.password().is_some()
|
|
|| target.fragment().is_some()
|
|
|| [refresh_token, client_id, client_secret].contains(&"")
|
|
{
|
|
return Err(Error);
|
|
}
|
|
let body = Zeroizing::new(
|
|
url::form_urlencoded::Serializer::new(String::new())
|
|
.append_pair("grant_type", "refresh_token")
|
|
.append_pair("refresh_token", refresh_token)
|
|
.append_pair("client_id", client_id)
|
|
.append_pair("client_secret", client_secret)
|
|
.finish(),
|
|
);
|
|
Ok(Request {
|
|
url: target.into(),
|
|
method: "POST",
|
|
headers: vec![
|
|
("Content-Type", "application/x-www-form-urlencoded".into()),
|
|
("Content-Length", body.len().to_string()),
|
|
],
|
|
body,
|
|
})
|
|
}
|
|
fn allowed(item: &Item, request: &Request) -> bool {
|
|
let Ok(target) = url::Url::parse(&request.url) else {
|
|
return false;
|
|
};
|
|
target.scheme() == "https"
|
|
&& target.username().is_empty()
|
|
&& target.password().is_none()
|
|
&& target.fragment().is_none()
|
|
&& request.method == "POST"
|
|
&& target.host_str().is_some_and(|host| {
|
|
item.rules
|
|
.iter()
|
|
.any(|rule| rule_matches(rule, target.scheme(), host, target.path(), "POST"))
|
|
})
|
|
}
|
|
#[derive(Clone)]
|
|
pub struct Token {
|
|
pub value: Zeroizing<String>,
|
|
pub scopes: Option<Vec<String>>,
|
|
pub expires: Instant,
|
|
}
|
|
pub fn decode(bytes: &[u8]) -> Result<Token, Error> {
|
|
#[derive(serde::Deserialize)]
|
|
struct Response {
|
|
access_token: Zeroizing<String>,
|
|
token_type: String,
|
|
expires_in: u64,
|
|
scope: Option<String>,
|
|
}
|
|
let r: Response = serde_json::from_slice(bytes).map_err(|_| Error)?;
|
|
// Validate header bytes without a second, non-zeroizing token allocation.
|
|
if r.access_token.is_empty()
|
|
|| !r.token_type.eq_ignore_ascii_case("bearer")
|
|
|| r.expires_in == 0
|
|
|| r.access_token
|
|
.bytes()
|
|
.any(|b| b != b'\t' && (b < 32 || b == 127))
|
|
{
|
|
return Err(Error);
|
|
}
|
|
Ok(Token {
|
|
value: r.access_token,
|
|
scopes: r
|
|
.scope
|
|
.map(|s| s.split_whitespace().map(str::to_owned).collect()),
|
|
expires: Instant::now() + Duration::from_secs(r.expires_in.min(86400).saturating_sub(30)),
|
|
})
|
|
}
|
|
#[derive(Default)]
|
|
struct State {
|
|
tokens: BTreeMap<(String, i64), Token>,
|
|
busy: BTreeSet<String>,
|
|
epoch: u64,
|
|
}
|
|
/// No mutex guard crosses HTTP await. Clear this cache on vault lock or item changes.
|
|
#[derive(Default)]
|
|
pub struct Cache {
|
|
state: Mutex<State>,
|
|
}
|
|
struct Flight<'a> {
|
|
cache: &'a Cache,
|
|
item: String,
|
|
}
|
|
impl Drop for Flight<'_> {
|
|
fn drop(&mut self) {
|
|
if let Ok(mut state) = self.cache.state.lock() {
|
|
state.busy.remove(&self.item);
|
|
}
|
|
}
|
|
}
|
|
impl Cache {
|
|
pub fn clear(&self) -> Result<(), Error> {
|
|
let mut state = self.state.lock().map_err(|_| Error)?;
|
|
state.tokens.clear();
|
|
state.epoch += 1;
|
|
Ok(())
|
|
}
|
|
pub async fn refresh(
|
|
&self,
|
|
item: &Item,
|
|
request: &Request,
|
|
http: &impl Http,
|
|
deadline: Instant,
|
|
) -> Result<Token, Error> {
|
|
if !allowed(item, request) || Instant::now() >= deadline {
|
|
return Err(Error);
|
|
}
|
|
let key = (item.id.clone(), item.version);
|
|
let epoch;
|
|
{
|
|
let mut state = self.state.lock().map_err(|_| Error)?;
|
|
if let Some(t) = state
|
|
.tokens
|
|
.get(&key)
|
|
.filter(|t| t.expires > Instant::now())
|
|
{
|
|
return Ok(t.clone());
|
|
}
|
|
state.tokens.retain(|(id, _), _| id != &item.id);
|
|
if !state.busy.insert(item.id.clone()) {
|
|
return Err(Error);
|
|
}
|
|
epoch = state.epoch;
|
|
}
|
|
let _flight = Flight {
|
|
cache: self,
|
|
item: item.id.clone(),
|
|
};
|
|
let response = http.send_once(request, deadline).await?;
|
|
if !response.status.is_success() {
|
|
return Err(Error);
|
|
}
|
|
let token = decode(&response.body)?;
|
|
let mut state = self.state.lock().map_err(|_| Error)?;
|
|
if state.epoch != epoch || Instant::now() >= deadline {
|
|
return Err(Error);
|
|
}
|
|
state.tokens.insert(key, token.clone());
|
|
Ok(token)
|
|
}
|
|
|
|
/// Injection entry point. Replace the refresh field before any transform sees these values.
|
|
/// The caller must reserve any additional grant use before this exchange.
|
|
pub async fn fields_for_injection(
|
|
&self,
|
|
item: &Item,
|
|
mut fields: BTreeMap<String, Zeroizing<String>>,
|
|
http: &impl Http,
|
|
deadline: Instant,
|
|
) -> Result<BTreeMap<String, Zeroizing<String>>, Error> {
|
|
let mut refresh = item
|
|
.fields
|
|
.iter()
|
|
.filter(|f| f.field_type == FieldType::Oauth2RefreshToken);
|
|
let field = refresh.next().ok_or(Error)?;
|
|
if field.public || refresh.next().is_some() {
|
|
return Err(Error);
|
|
}
|
|
let value = |name: &str| {
|
|
fields
|
|
.get(name)
|
|
.map(|s| s.as_str())
|
|
.filter(|s| !s.is_empty())
|
|
.ok_or(Error)
|
|
};
|
|
let req = request(
|
|
value("token_url")?,
|
|
value(&field.name)?,
|
|
value("client_id")?,
|
|
value("client_secret")?,
|
|
)?;
|
|
let token = self.refresh(item, &req, http, deadline).await?;
|
|
fields.insert(field.name.clone(), token.value);
|
|
Ok(fields)
|
|
}
|
|
}
|