use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use oci_spec::distribution::Reference;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::fmt;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::RwLock;
use tracing::{debug, warn};
#[derive(Deserialize, Clone)]
#[serde(untagged)]
#[serde(rename_all = "snake_case")]
pub enum RegistryToken {
Token {
token: String,
},
AccessToken {
access_token: String,
},
}
impl fmt::Debug for RegistryToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let redacted = String::from("<redacted>");
match self {
RegistryToken::Token { .. } => {
f.debug_struct("Token").field("token", &redacted).finish()
}
RegistryToken::AccessToken { .. } => f
.debug_struct("AccessToken")
.field("access_token", &redacted)
.finish(),
}
}
}
#[derive(Debug, Clone)]
pub enum RegistryTokenType {
Bearer(RegistryToken),
Basic(String, String),
}
impl RegistryToken {
pub fn bearer_token(&self) -> String {
format!("Bearer {}", self.token())
}
pub fn token(&self) -> &str {
match self {
RegistryToken::Token { token } => token,
RegistryToken::AccessToken { access_token } => access_token,
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum RegistryOperation {
Push,
Pull,
}
#[derive(Debug, Deserialize)]
struct BearerTokenClaims {
exp: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct TokenCacheKey {
registry: String,
repository: String,
operation: RegistryOperation,
}
struct TokenCacheValue {
token: RegistryTokenType,
expiration: u64,
}
#[derive(Clone)]
pub struct TokenCache {
tokens: Arc<RwLock<BTreeMap<TokenCacheKey, TokenCacheValue>>>,
pub default_expiration_secs: usize,
}
impl TokenCache {
pub(crate) fn new(default_expiration_secs: usize) -> Self {
TokenCache {
tokens: Arc::new(RwLock::new(BTreeMap::new())),
default_expiration_secs,
}
}
pub async fn insert(
&self,
reference: &Reference,
op: RegistryOperation,
token: RegistryTokenType,
) {
let expiration = match token {
RegistryTokenType::Basic(_, _) => u64::MAX,
RegistryTokenType::Bearer(ref t) => {
match bearer_token_cache_expiration(t.token(), self.default_expiration_secs) {
Some(value) => value,
None => return,
}
}
};
let registry = reference.resolve_registry().to_string();
let repository = reference.repository().to_string();
debug!(%registry, %repository, ?op, %expiration, "Inserting token");
self.tokens.write().await.insert(
TokenCacheKey {
registry,
repository,
operation: op,
},
TokenCacheValue { token, expiration },
);
}
pub(crate) async fn get(
&self,
reference: &Reference,
op: RegistryOperation,
) -> Option<RegistryTokenType> {
let registry = reference.resolve_registry().to_string();
let repository = reference.repository().to_string();
let key = TokenCacheKey {
registry,
repository,
operation: op,
};
match self.tokens.read().await.get(&key) {
Some(TokenCacheValue {
ref token,
expiration,
}) => {
let now = SystemTime::now();
let epoch = now
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs();
if epoch > *expiration {
debug!(%key.registry, %key.repository, ?key.operation, %expiration, miss=false, expired=true, "Fetching token");
None
} else {
debug!(%key.registry, %key.repository, ?key.operation, %expiration, miss=false, expired=false, "Fetching token");
Some(token.clone())
}
}
None => {
debug!(%key.registry, %key.repository, ?key.operation, miss = true, "Fetching token");
None
}
}
}
}
const MAX_TOKEN_CACHE_TTL_SECS: u64 = 24 * 60 * 60;
fn bearer_token_cache_expiration(token_str: &str, default_expiration_secs: usize) -> Option<u64> {
let mut parts = token_str.split('.');
let (Some(_header), Some(payload), Some(_signature), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
debug!(
"Bearer token is not a JWT, assuming a {} seconds validity",
default_expiration_secs
);
return Some(default_expiration(default_expiration_secs));
};
let payload = match URL_SAFE_NO_PAD.decode(payload) {
Ok(payload) => payload,
Err(error) => {
warn!(?error, "Invalid bearer token payload encoding");
return None;
}
};
let claims: BearerTokenClaims = match serde_json::from_slice(&payload) {
Ok(claims) => claims,
Err(error) => {
warn!(?error, "Invalid bearer token payload");
return None;
}
};
let exp = match claims.exp {
Some(exp) => exp,
None => {
debug!(
"Cannot extract expiration from token's claims, assuming a {} seconds validity",
default_expiration_secs
);
default_expiration(default_expiration_secs)
}
};
let max_exp = default_expiration(MAX_TOKEN_CACHE_TTL_SECS as usize);
Some(exp.min(max_exp))
}
fn default_expiration(default_expiration_secs: usize) -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs()
+ default_expiration_secs as u64
}
#[cfg(test)]
mod tests {
use super::*;
use oci_spec::distribution::Reference;
use rstest::rstest;
use serde::Serialize;
const OPAQUE_TOKEN: &str = "ghs_exampleOpaqueTokenFromGHCR1234567890";
#[derive(Serialize)]
struct ClaimsWithExp {
exp: u64,
}
#[derive(Serialize)]
struct ClaimsWithoutExp {
sub: &'static str,
}
fn make_jwt_with_exp(exp: u64) -> String {
make_jwt(&ClaimsWithExp { exp })
}
fn make_jwt_without_exp() -> String {
make_jwt(&ClaimsWithoutExp { sub: "test" })
}
fn make_jwt(claims: &impl Serialize) -> String {
let payload = serde_json::to_vec(claims).expect("failed to serialize JWT claims");
format!("e30.{}.signature", URL_SAFE_NO_PAD.encode(payload))
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[test]
fn jwt_with_near_exp_uses_claims_expiration() {
let exp = now_secs() + 3600;
let token = make_jwt_with_exp(exp);
let cached_exp = bearer_token_cache_expiration(&token, 60)
.expect("should return Some for valid JWT with exp");
assert_eq!(cached_exp, exp);
}
#[test]
fn jwt_with_far_future_exp_is_capped() {
let token = make_jwt_with_exp(9999999999);
let before = now_secs();
let cached_exp = bearer_token_cache_expiration(&token, 60)
.expect("should return Some for valid JWT with exp");
let after = now_secs();
assert!(cached_exp < 9999999999);
assert!(cached_exp >= before + MAX_TOKEN_CACHE_TTL_SECS);
assert!(cached_exp <= after + MAX_TOKEN_CACHE_TTL_SECS);
}
#[rstest]
#[case::jwt_without_exp(make_jwt_without_exp())]
#[case::opaque_token(OPAQUE_TOKEN.to_string())]
#[case::five_part_jwe("a.b.c.d.e".to_string())]
fn token_without_readable_exp_uses_default_expiration(#[case] token: String) {
let before = now_secs();
let exp = bearer_token_cache_expiration(&token, 60)
.expect("should return Some with default expiration");
let after = now_secs();
assert!(exp >= before + 60);
assert!(exp <= after + 60);
}
#[rstest]
#[case::invalid_base64("not-valid-base64!!!".to_string())]
#[case::empty_segment("".to_string())]
#[case::padded_base64("eyJzdWIiOiJ0ZXN0In0=".to_string())]
#[case::not_json(URL_SAFE_NO_PAD.encode(b"not json"))]
#[case::exp_as_string(URL_SAFE_NO_PAD.encode(br#"{"exp":"9999999999"}"#))]
#[case::exp_negative(URL_SAFE_NO_PAD.encode(br#"{"exp":-1}"#))]
#[case::exp_float(URL_SAFE_NO_PAD.encode(br#"{"exp":1.5}"#))]
fn malformed_jwt_payload_returns_none(#[case] payload: String) {
let token = format!("e30.{payload}.signature");
assert!(bearer_token_cache_expiration(&token, 60).is_none());
}
#[tokio::test]
async fn opaque_token_is_cached() {
let cache = TokenCache::new(60);
let reference: Reference = "ghcr.io/kubewarden/policies/pod-privileged:v1.0.10"
.parse()
.unwrap();
let token = RegistryTokenType::Bearer(RegistryToken::Token {
token: OPAQUE_TOKEN.to_string(),
});
cache
.insert(&reference, RegistryOperation::Pull, token)
.await;
assert!(
cache
.get(&reference, RegistryOperation::Pull)
.await
.is_some(),
"opaque bearer token should be cached"
);
}
}