use std::time::{Duration, SystemTime};
use crate::error::{Error, ErrorKind};
use crate::region::Region;
const HOST: &str = "bedrock.amazonaws.com";
const SERVICE: &str = "bedrock";
const ACTION: &str = "CallWithBearerToken";
const PREFIX: &str = "bedrock-api-key-";
const VERSION_SUFFIX: &str = "&Version=1";
pub const MAX_LIFETIME: Duration = Duration::from_secs(43_200);
#[derive(Clone, PartialEq, Eq)]
pub struct BearerToken(String);
impl BearerToken {
pub fn expose(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for BearerToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "BearerToken(<{} bytes>)", self.0.len())
}
}
#[derive(Clone, Debug)]
pub struct Minted {
pub token: BearerToken,
pub expires_at: SystemTime,
}
pub async fn mint(region: &Region, lifetime: Duration) -> Result<Minted, Error> {
use aws_credential_types::provider::ProvideCredentials as _;
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
.region(aws_config::Region::new(region.as_str().to_string()))
.load()
.await;
let provider = config.credentials_provider().ok_or_else(|| {
Error::new(
ErrorKind::Credentials,
"no credentials provider resolved, so no Bedrock token can be minted. The \
default chain looks at environment variables, the shared config files, SSO, a \
credential process, then the EC2 instance metadata service; none of them \
answered.",
)
})?;
let credentials = provider.provide_credentials().await.map_err(|error| {
Error::new(
ErrorKind::Credentials,
format!("the credential chain resolved but could not provide credentials: {error}"),
)
.with_source(error)
})?;
mint_with(&credentials, region, lifetime, SystemTime::now())
}
pub fn mint_with(
credentials: &aws_credential_types::Credentials,
region: &Region,
lifetime: Duration,
now: SystemTime,
) -> Result<Minted, Error> {
use aws_sigv4::http_request::{
SignableBody, SignableRequest, SignatureLocation, SigningSettings, sign,
};
use aws_sigv4::sign::v4;
use base64::Engine as _;
if lifetime.is_zero() || lifetime > MAX_LIFETIME {
return Err(Error::invalid_arg(format!(
"a Bedrock bearer token lives between 1 and {} seconds ({} hours); {} seconds \
was asked for. The ceiling is the token generator's `TOKEN_DURATION`, and the \
service additionally caps validity at the signing credentials' own expiry.",
MAX_LIFETIME.as_secs(),
MAX_LIFETIME.as_secs() / 3600,
lifetime.as_secs(),
)));
}
let mut settings = SigningSettings::default();
settings.signature_location = SignatureLocation::QueryParams;
settings.expires_in = Some(lifetime);
let identity = credentials.clone().into();
let params: aws_sigv4::http_request::SigningParams = v4::SigningParams::builder()
.identity(&identity)
.region(region.as_str())
.name(SERVICE)
.time(now)
.settings(settings)
.build()
.map_err(|error| {
Error::new(
ErrorKind::Unexpected,
format!("could not build SigV4 presign parameters: {error}"),
)
.with_source(error)
})?
.into();
let url = format!("https://{HOST}/?Action={ACTION}");
let mut request = http::Request::builder()
.method("POST")
.uri(&url)
.header("host", HOST)
.body(Vec::<u8>::new())
.map_err(|error| {
Error::new(
ErrorKind::Unexpected,
format!("could not build the presign request: {error}"),
)
.with_source(error)
})?;
let instructions = {
let signable = SignableRequest::new(
"POST",
&url,
[("host", HOST)].into_iter(),
SignableBody::Bytes(&[]),
)
.map_err(|error| {
Error::new(
ErrorKind::Unexpected,
format!("could not build the signable presign request: {error}"),
)
.with_source(error)
})?;
sign(signable, ¶ms)
.map_err(|error| {
Error::new(
ErrorKind::Unexpected,
format!("SigV4 presigning failed: {error}"),
)
.with_source(error)
})?
.into_parts()
.0
};
instructions.apply_to_request_http1x(&mut request);
let presigned = request.uri().to_string();
let stripped = presigned.strip_prefix("https://").ok_or_else(|| {
Error::new(
ErrorKind::Unexpected,
format!("the presigned URL did not start with https://: {presigned}"),
)
})?;
let payload = format!("{stripped}{VERSION_SUFFIX}");
let token = format!(
"{PREFIX}{}",
base64::engine::general_purpose::STANDARD.encode(payload.as_bytes())
);
Ok(Minted {
token: BearerToken(token),
expires_at: now + lifetime,
})
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine as _;
fn credentials() -> aws_credential_types::Credentials {
aws_credential_types::Credentials::new(
"AKIDEXAMPLE",
"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
Some("SESSIONTOKENEXAMPLE".to_string()),
None,
"test",
)
}
fn decode(token: &BearerToken) -> String {
let encoded = token
.expose()
.strip_prefix(PREFIX)
.expect("the reference prefix");
String::from_utf8(
base64::engine::general_purpose::STANDARD
.decode(encoded)
.expect("standard base64"),
)
.expect("utf-8")
}
#[test]
fn the_token_decodes_to_the_reference_presigned_url() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_789_000_000);
let minted = mint_with(
&credentials(),
&Region::UsEast1,
Duration::from_secs(3600),
now,
)
.expect("mints");
let url = decode(&minted.token);
assert!(
url.starts_with("bedrock.amazonaws.com/?"),
"the host is fixed and the scheme is stripped: {url}"
);
assert!(url.contains("Action=CallWithBearerToken"), "{url}");
assert!(url.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"), "{url}");
assert!(url.contains("X-Amz-Expires=3600"), "{url}");
assert!(url.contains("X-Amz-SignedHeaders=host"), "{url}");
assert!(
url.contains("X-Amz-Security-Token=SESSIONTOKENEXAMPLE"),
"{url}"
);
assert!(
url.contains("%2Fus-east-1%2Fbedrock%2Faws4_request"),
"the credential scope names the region and the bedrock service: {url}"
);
assert!(url.contains("X-Amz-Signature="), "{url}");
assert!(
url.ends_with("&Version=1"),
"the version suffix is appended after signing, so it is last: {url}"
);
assert_eq!(minted.expires_at, now + Duration::from_secs(3600));
}
#[test]
fn a_lifetime_outside_the_reference_range_is_refused() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_789_000_000);
for lifetime in [Duration::ZERO, MAX_LIFETIME + Duration::from_secs(1)] {
let error =
mint_with(&credentials(), &Region::UsEast1, lifetime, now).expect_err("refused");
assert_eq!(error.kind(), ErrorKind::InvalidArg);
assert!(error.to_string().contains("43200"), "{error}");
}
mint_with(&credentials(), &Region::UsEast1, MAX_LIFETIME, now)
.expect("the ceiling itself is legal");
}
#[test]
fn minting_is_deterministic_in_its_inputs_and_sensitive_to_the_region() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_789_000_000);
let a = mint_with(&credentials(), &Region::UsEast1, MAX_LIFETIME, now).expect("a");
let b = mint_with(&credentials(), &Region::UsEast1, MAX_LIFETIME, now).expect("b");
let c = mint_with(&credentials(), &Region::EuWest1, MAX_LIFETIME, now).expect("c");
assert_eq!(a.token, b.token);
assert_ne!(a.token, c.token);
}
#[test]
fn debug_prints_the_length_and_never_the_token() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_789_000_000);
let minted = mint_with(&credentials(), &Region::UsEast1, MAX_LIFETIME, now).expect("m");
let shown = format!("{:?}", minted.token);
assert!(shown.starts_with("BearerToken(<"), "{shown}");
assert!(!shown.contains(PREFIX), "{shown}");
assert!(!shown.contains("X-Amz"), "{shown}");
}
}