use std::collections::BTreeMap;
use std::fmt::Write as _;
use async_trait::async_trait;
use serde::Deserialize;
use crate::core::Secret;
use super::credentials::{CredentialError, TokenExchange};
use super::{PeerCredential, PeerId};
pub const TOKEN_EXCHANGE_GRANT: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
pub const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token";
pub struct TokenEndpoint {
url: String,
http: reqwest::Client,
subject_token_type: String,
actor_token: Secret,
actor_token_type: String,
client: Option<(String, Secret)>,
resources: BTreeMap<PeerId, String>,
}
impl std::fmt::Debug for TokenEndpoint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenEndpoint")
.field("url", &self.url)
.field("subject_token_type", &self.subject_token_type)
.field("actor_token_type", &self.actor_token_type)
.field("client", &self.client.as_ref().map(|(id, _)| id))
.field("resources", &self.resources)
.finish_non_exhaustive()
}
}
#[derive(Deserialize)]
struct Issued {
access_token: String,
#[serde(default)]
expires_in: Option<u64>,
}
#[derive(Deserialize)]
struct Refused {
error: String,
}
impl TokenEndpoint {
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
pub fn new(
url: impl Into<String>,
subject_token_type: impl Into<String>,
actor_token: impl Into<String>,
) -> Result<Self, CredentialError> {
let url = url.into();
let refused = |detail: String| CredentialError::Unavailable {
audience: PeerId::new("token-endpoint"),
detail,
};
let parsed = reqwest::Url::parse(&url)
.map_err(|e| refused(format!("the token endpoint is not a URL: {e}")))?;
let local = cfg!(feature = "testkit")
&& parsed
.host_str()
.is_some_and(|h| crate::netguard::is_loopback_name(&h.to_ascii_lowercase()));
if parsed.scheme() != "https" && !local {
return Err(refused(format!(
"the token endpoint on '{}' is not https — the plane's own credential \
and the issued tokens must not cross the network in cleartext",
parsed.host_str().unwrap_or("")
)));
}
let http = crate::netguard::guarded_client(crate::netguard::Reach::Configured)
.timeout(Self::TIMEOUT)
.build()
.map_err(|e| refused(format!("could not build an HTTP client: {e}")))?;
Ok(Self {
url,
http,
subject_token_type: subject_token_type.into(),
actor_token: Secret::new(actor_token),
actor_token_type: ACCESS_TOKEN_TYPE.to_owned(),
client: None,
resources: BTreeMap::new(),
})
}
#[must_use]
pub fn actor_token_type(mut self, token_type: impl Into<String>) -> Self {
self.actor_token_type = token_type.into();
self
}
#[must_use]
pub fn client(mut self, id: impl Into<String>, secret: impl Into<String>) -> Self {
self.client = Some((id.into(), Secret::new(secret)));
self
}
#[must_use]
pub fn resource(mut self, peer: PeerId, uri: impl Into<String>) -> Self {
self.resources.insert(peer, uri.into());
self
}
fn form(&self, audience: &PeerId, subject: &str) -> String {
let mut pairs = vec![
("grant_type", TOKEN_EXCHANGE_GRANT),
("subject_token", subject),
("subject_token_type", self.subject_token_type.as_str()),
("actor_token", self.actor_token.expose()),
("actor_token_type", self.actor_token_type.as_str()),
("requested_token_type", ACCESS_TOKEN_TYPE),
("audience", audience.0.as_str()),
];
if let Some(resource) = self.resources.get(audience) {
pairs.push(("resource", resource.as_str()));
}
pairs
.into_iter()
.map(|(k, v)| format!("{}={}", form_encode(k), form_encode(v)))
.collect::<Vec<_>>()
.join("&")
}
}
fn form_encode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'*' => {
out.push(char::from(byte));
}
b' ' => out.push('+'),
other => {
let _ = write!(out, "%{other:02X}");
}
}
}
out
}
#[async_trait]
impl TokenExchange for TokenEndpoint {
async fn exchange(
&self,
audience: &PeerId,
subject: &str,
) -> Result<PeerCredential, CredentialError> {
let unavailable = |detail: String| CredentialError::Unavailable {
audience: audience.clone(),
detail,
};
let mut request = self
.http
.post(&self.url)
.header(
reqwest::header::CONTENT_TYPE,
"application/x-www-form-urlencoded",
)
.header(reqwest::header::ACCEPT, "application/json")
.body(self.form(audience, subject));
if let Some((id, secret)) = &self.client {
request = request.basic_auth(form_encode(id), Some(form_encode(secret.expose())));
}
let response = request
.send()
.await
.map_err(|e| unavailable(crate::netguard::transport_text(&e)))?;
let status = response.status();
let body = crate::netguard::intake::read(response, crate::netguard::intake::METADATA)
.await
.map_err(|e| unavailable(e.to_string()))?;
if !status.is_success() {
let code = serde_json::from_slice::<Refused>(&body)
.map_or_else(|_| "no error code".to_owned(), |r| r.error);
let detail = format!("the token endpoint answered {status} ({code})");
let answered = status.is_client_error()
&& status != reqwest::StatusCode::REQUEST_TIMEOUT
&& status != reqwest::StatusCode::TOO_MANY_REQUESTS;
return Err(if answered {
CredentialError::Refused {
audience: audience.clone(),
detail,
}
} else {
unavailable(detail)
});
}
let issued: Issued = serde_json::from_slice(&body)
.map_err(|e| unavailable(format!("the token endpoint's answer is not a token: {e}")))?;
let mut credential =
PeerCredential::for_subject(audience.clone(), subject, issued.access_token);
if let Some(seconds) = issued.expires_in {
let lifetime = time::Duration::seconds(i64::try_from(seconds).unwrap_or(i64::MAX));
if let Some(at) = super::credentials::now().checked_add(lifetime) {
credential = credential.expiring_at(at);
}
}
Ok(credential)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_form_names_the_owner_the_actor_and_the_peer() {
let endpoint = TokenEndpoint::new(
"https://issuer.example/token",
"urn:example:principal",
"plane secret",
)
.expect("an https endpoint")
.resource(PeerId::new("reviewer"), "https://reviewer.example/a2a");
let form = endpoint.form(&PeerId::new("reviewer"), "user:alice");
for pair in [
"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange",
"subject_token=user%3Aalice",
"subject_token_type=urn%3Aexample%3Aprincipal",
"actor_token=plane+secret",
"actor_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token",
"audience=reviewer",
"resource=https%3A%2F%2Freviewer.example%2Fa2a",
] {
assert!(
form.split('&').any(|p| p == pair),
"the exchange did not post {pair}: {form}"
);
}
}
#[test]
fn a_plaintext_token_endpoint_is_refused() {
let err =
TokenEndpoint::new("http://issuer.example/token", "t", "a").expect_err("plaintext");
assert!(err.to_string().contains("not https"), "{err}");
}
}