use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
const MAX_AUTHORIZATION_BYTES: usize = 8 * 1024;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tier {
Anon,
Read,
Write,
Approve,
}
#[derive(Clone)]
pub struct Tokens {
pub read: Option<Vec<u8>>,
pub write: Option<Vec<u8>>,
pub approve: Option<Vec<u8>>,
}
impl Tokens {
pub fn from_env() -> Self {
Self {
read: nonempty_env("ELASTIK_READ_TOKEN"),
write: nonempty_env("ELASTIK_WRITE_TOKEN").or_else(|| nonempty_env("ELASTIK_TOKEN")),
approve: nonempty_env("ELASTIK_APPROVE_TOKEN"),
}
}
pub fn read_required(&self) -> bool {
self.read.is_some()
}
pub fn check(&self, authorization: Option<&str>) -> Tier {
let Some(value) = authorization else {
return Tier::Anon;
};
if value.len() > MAX_AUTHORIZATION_BYTES {
return Tier::Anon;
}
let Some((scheme, credentials)) = value.split_once(char::is_whitespace) else {
return Tier::Anon;
};
let credentials = credentials.trim();
if scheme.eq_ignore_ascii_case("Bearer") {
return self.check_token_bytes(credentials.as_bytes());
}
if scheme.eq_ignore_ascii_case("Basic") {
if let Ok(decoded) = B64.decode(credentials) {
if let Some(idx) = decoded.iter().position(|&b| b == b':') {
return self.check_token_bytes(&decoded[idx + 1..]);
}
}
}
Tier::Anon
}
pub(crate) fn check_token_bytes(&self, candidate: &[u8]) -> Tier {
if candidate.is_empty() {
return Tier::Anon;
}
if let Some(t) = &self.approve {
if ct_eq(candidate, t) {
return Tier::Approve;
}
}
if let Some(t) = &self.write {
if ct_eq(candidate, t) {
return Tier::Write;
}
}
if let Some(t) = &self.read {
if ct_eq(candidate, t) {
return Tier::Read;
}
}
Tier::Anon
}
}
fn nonempty_env(name: &str) -> Option<Vec<u8>> {
match std::env::var(name) {
Ok(s) if !s.trim().is_empty() => Some(s.into_bytes()),
_ => None,
}
}
pub fn env_set_but_empty(name: &str) -> bool {
match std::env::var(name) {
Ok(s) => s.trim().is_empty(),
Err(_) => false,
}
}
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
struct EnvGuard {
read: Option<String>,
write: Option<String>,
legacy_write: Option<String>,
approve: Option<String>,
}
impl EnvGuard {
fn capture() -> Self {
Self {
read: std::env::var("ELASTIK_READ_TOKEN").ok(),
write: std::env::var("ELASTIK_WRITE_TOKEN").ok(),
legacy_write: std::env::var("ELASTIK_TOKEN").ok(),
approve: std::env::var("ELASTIK_APPROVE_TOKEN").ok(),
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.read {
Some(v) => std::env::set_var("ELASTIK_READ_TOKEN", v),
None => std::env::remove_var("ELASTIK_READ_TOKEN"),
}
match &self.write {
Some(v) => std::env::set_var("ELASTIK_WRITE_TOKEN", v),
None => std::env::remove_var("ELASTIK_WRITE_TOKEN"),
}
match &self.legacy_write {
Some(v) => std::env::set_var("ELASTIK_TOKEN", v),
None => std::env::remove_var("ELASTIK_TOKEN"),
}
match &self.approve {
Some(v) => std::env::set_var("ELASTIK_APPROVE_TOKEN", v),
None => std::env::remove_var("ELASTIK_APPROVE_TOKEN"),
}
}
}
#[test]
fn from_env_treats_empty_tokens_as_disabled() {
let _lock = env_lock().lock().unwrap();
let _env = EnvGuard::capture();
std::env::set_var("ELASTIK_READ_TOKEN", " ");
std::env::set_var("ELASTIK_WRITE_TOKEN", "");
std::env::remove_var("ELASTIK_TOKEN");
std::env::set_var("ELASTIK_APPROVE_TOKEN", " ");
let tokens = Tokens::from_env();
assert_eq!(tokens.read, None);
assert_eq!(tokens.write, None);
assert_eq!(tokens.approve, None);
assert_eq!(tokens.check(Some("Bearer ")), Tier::Anon);
assert_eq!(tokens.check(Some("Basic Og==")), Tier::Anon);
assert!(env_set_but_empty("ELASTIK_READ_TOKEN"));
assert!(env_set_but_empty("ELASTIK_WRITE_TOKEN"));
assert!(env_set_but_empty("ELASTIK_APPROVE_TOKEN"));
}
#[test]
fn legacy_elastik_token_is_a_write_token_fallback() {
let _lock = env_lock().lock().unwrap();
let _env = EnvGuard::capture();
std::env::remove_var("ELASTIK_WRITE_TOKEN");
std::env::set_var("ELASTIK_TOKEN", "legacy-writer");
let tokens = Tokens::from_env();
assert_eq!(tokens.check(Some("Bearer legacy-writer")), Tier::Write);
}
#[test]
fn empty_authorization_candidate_never_matches() {
let tokens = Tokens {
read: Some(Vec::new()),
write: Some(Vec::new()),
approve: Some(Vec::new()),
};
assert_eq!(tokens.check(Some("Bearer ")), Tier::Anon);
assert_eq!(tokens.check(Some("Basic Og==")), Tier::Anon);
}
#[test]
fn oversized_authorization_header_is_anon() {
let tokens = Tokens {
read: Some(b"reader".to_vec()),
write: Some(b"writer".to_vec()),
approve: Some(b"approve".to_vec()),
};
let header = format!("Bearer {}", "x".repeat(MAX_AUTHORIZATION_BYTES));
assert_eq!(tokens.check(Some(&header)), Tier::Anon);
}
#[test]
fn nonempty_tokens_still_authenticate() {
let tokens = Tokens {
read: Some(b"reader".to_vec()),
write: Some(b"writer".to_vec()),
approve: Some(b"approve".to_vec()),
};
let basic_writer = B64.encode("user:writer");
assert_eq!(tokens.check(Some("Bearer reader")), Tier::Read);
assert_eq!(tokens.check(Some("bearer reader")), Tier::Read);
assert_eq!(tokens.check(Some("Bearer writer")), Tier::Write);
assert_eq!(
tokens.check(Some(&format!("Basic {basic_writer}"))),
Tier::Write
);
assert_eq!(
tokens.check(Some(&format!("basic {basic_writer}"))),
Tier::Write
);
assert_eq!(tokens.check(Some("Bearer approve")), Tier::Approve);
assert_eq!(tokens.check(Some("Bearer ")), Tier::Anon);
}
}