use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use tonic::Status;
#[derive(Debug, Clone)]
pub struct BasicCredential {
pub username: String,
pub password: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Identity {
Token(String),
Basic(String),
Anonymous,
}
impl Identity {
pub fn name(&self) -> &str {
match self {
Identity::Token(name) | Identity::Basic(name) => name,
Identity::Anonymous => "*",
}
}
}
#[derive(Clone, Default)]
pub enum Authorizer {
#[default]
AllowAll,
AllowList(HashMap<String, HashSet<String>>),
Custom(Arc<dyn Fn(&Identity, &str) -> bool + Send + Sync>),
}
impl std::fmt::Debug for Authorizer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Authorizer::AllowAll => f.write_str("Authorizer::AllowAll"),
Authorizer::AllowList(m) => f.debug_tuple("Authorizer::AllowList").field(m).finish(),
Authorizer::Custom(_) => f.write_str("Authorizer::Custom(<fn>)"),
}
}
}
impl Authorizer {
pub fn allow_list<I, S1, S2>(entries: I) -> Self
where
I: IntoIterator<Item = (S1, Vec<S2>)>,
S1: Into<String>,
S2: Into<String>,
{
let map = entries
.into_iter()
.map(|(id, queries)| (id.into(), queries.into_iter().map(Into::into).collect()))
.collect();
Authorizer::AllowList(map)
}
pub fn custom<F>(f: F) -> Self
where
F: Fn(&Identity, &str) -> bool + Send + Sync + 'static,
{
Authorizer::Custom(Arc::new(f))
}
pub fn is_allowed(&self, identity: &Identity, query_name: &str) -> bool {
match self {
Authorizer::AllowAll => true,
Authorizer::AllowList(map) => {
let allowed = |key: &str| map.get(key).is_some_and(|qs| qs.contains(query_name));
allowed(identity.name()) || allowed("*")
}
Authorizer::Custom(f) => f(identity, query_name),
}
}
pub fn authorize(&self, identity: &Identity, query_name: &str) -> Result<(), Status> {
if self.is_allowed(identity, query_name) {
Ok(())
} else {
Err(Status::permission_denied(format!(
"identity '{}' is not permitted to run query '{query_name}'",
identity.name()
)))
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AuthConfig {
bearer_tokens: Vec<(String, String)>,
pub basic: Option<BasicCredential>,
pub authorizer: Authorizer,
}
impl AuthConfig {
pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
self.add_bearer_token(token);
self
}
pub fn with_bearer_tokens<I, S>(mut self, tokens: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
for t in tokens {
self.add_bearer_token(t);
}
self
}
pub fn with_named_bearer_tokens<I, S1, S2>(mut self, tokens: I) -> Self
where
I: IntoIterator<Item = (S1, S2)>,
S1: Into<String>,
S2: Into<String>,
{
for (token, name) in tokens {
self.add_named_bearer_token(token, name);
}
self
}
pub fn add_bearer_token(&mut self, token: impl Into<String>) -> &mut Self {
let token = token.into();
let name = token.clone();
self.add_named_bearer_token(token, name)
}
pub fn add_named_bearer_token(
&mut self,
token: impl Into<String>,
name: impl Into<String>,
) -> &mut Self {
self.bearer_tokens.push((token.into(), name.into()));
self
}
pub fn with_basic(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
self.basic = Some(BasicCredential {
username: username.into(),
password: password.into(),
});
self
}
pub fn with_authorizer(mut self, authorizer: Authorizer) -> Self {
self.authorizer = authorizer;
self
}
pub fn authorize(&self, identity: &Identity, query_name: &str) -> Result<(), Status> {
self.authorizer.authorize(identity, query_name)
}
pub fn bearer_tokens(&self) -> impl Iterator<Item = &str> {
self.bearer_tokens.iter().map(|(t, _)| t.as_str())
}
pub fn is_enabled(&self) -> bool {
!self.bearer_tokens.is_empty() || self.basic.is_some()
}
pub fn issued_token(&self) -> Option<&str> {
self.bearer_tokens.first().map(|(t, _)| t.as_str())
}
pub fn check_header(&self, header: Option<&str>) -> Result<(), Status> {
self.authenticate(header).map(|_| ())
}
pub fn authenticate(&self, header: Option<&str>) -> Result<Identity, Status> {
if !self.is_enabled() {
return Ok(Identity::Anonymous);
}
let value = header.ok_or_else(|| {
Status::unauthenticated(
"missing 'authorization' header (Bearer token or Basic credentials)",
)
})?;
if let Some(token) = value.strip_prefix("Bearer ") {
for (expected, name) in &self.bearer_tokens {
if constant_time_eq(token.as_bytes(), expected.as_bytes()) {
return Ok(Identity::Token(name.clone()));
}
}
} else if let Some(user) = value
.strip_prefix("Basic ")
.and_then(|b64| self.check_basic(b64))
{
return Ok(Identity::Basic(user));
}
Err(Status::unauthenticated("invalid credentials"))
}
pub fn check_basic(&self, b64: &str) -> Option<String> {
let expected = self.basic.as_ref()?;
let decoded = BASE64.decode(b64).ok()?;
let text = std::str::from_utf8(&decoded).ok()?;
let (user, pass) = text.split_once(':')?;
let ok = constant_time_eq(user.as_bytes(), expected.username.as_bytes())
& constant_time_eq(pass.as_bytes(), expected.password.as_bytes());
ok.then(|| user.to_string())
}
}
pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
#[cfg(test)]
mod tests {
use super::*;
fn basic_header(user: &str, pass: &str) -> String {
format!("Basic {}", BASE64.encode(format!("{user}:{pass}")))
}
#[test]
fn open_server_accepts_anything() {
let auth = AuthConfig::default();
assert!(!auth.is_enabled());
assert!(auth.check_header(None).is_ok());
assert!(auth.check_header(Some("garbage")).is_ok());
assert_eq!(auth.authenticate(None).unwrap(), Identity::Anonymous);
}
#[test]
fn bearer_token_accepted_and_rejected() {
let auth = AuthConfig::default().with_bearer_token("s3cret");
assert!(auth.check_header(Some("Bearer s3cret")).is_ok());
assert!(auth.check_header(Some("Bearer nope")).is_err());
assert!(auth.check_header(None).is_err());
assert_eq!(
auth.authenticate(Some("Bearer s3cret")).unwrap(),
Identity::Token("s3cret".into())
);
}
#[test]
fn multiple_tokens_all_accepted_for_rotation() {
let auth = AuthConfig::default().with_bearer_tokens(["old-tok", "new-tok"]);
assert!(auth.check_header(Some("Bearer old-tok")).is_ok());
assert!(auth.check_header(Some("Bearer new-tok")).is_ok());
assert!(auth.check_header(Some("Bearer retired-tok")).is_err());
assert_eq!(auth.issued_token(), Some("old-tok"));
}
#[test]
fn add_bearer_token_grows_the_set() {
let mut auth = AuthConfig::default().with_bearer_token("t1");
auth.add_bearer_token("t2");
assert!(auth.check_header(Some("Bearer t1")).is_ok());
assert!(auth.check_header(Some("Bearer t2")).is_ok());
assert_eq!(auth.bearer_tokens().collect::<Vec<_>>(), vec!["t1", "t2"]);
}
#[test]
fn named_tokens_share_an_identity() {
let auth = AuthConfig::default()
.with_named_bearer_tokens([("tok-a", "analyst"), ("tok-b", "analyst")]);
assert_eq!(
auth.authenticate(Some("Bearer tok-a")).unwrap(),
Identity::Token("analyst".into())
);
assert_eq!(
auth.authenticate(Some("Bearer tok-b")).unwrap(),
Identity::Token("analyst".into())
);
}
#[test]
fn basic_credentials_accepted_and_rejected() {
let auth = AuthConfig::default().with_basic("admin", "pw");
assert!(
auth.check_header(Some(&basic_header("admin", "pw")))
.is_ok()
);
assert!(
auth.check_header(Some(&basic_header("admin", "wrong")))
.is_err()
);
assert!(auth.check_header(Some(&basic_header("eve", "pw"))).is_err());
assert!(auth.check_header(Some("Basic !!!notbase64")).is_err());
assert_eq!(
auth.authenticate(Some(&basic_header("admin", "pw")))
.unwrap(),
Identity::Basic("admin".into())
);
}
#[test]
fn both_methods_accepted_when_both_configured() {
let auth = AuthConfig::default()
.with_bearer_token("tok")
.with_basic("u", "p");
assert!(auth.check_header(Some("Bearer tok")).is_ok());
assert!(auth.check_header(Some(&basic_header("u", "p"))).is_ok());
assert!(auth.check_header(Some("Bearer bad")).is_err());
}
#[test]
fn issued_token_is_the_first_bearer_token() {
let auth = AuthConfig::default().with_bearer_tokens(["tok", "tok2"]);
assert_eq!(auth.issued_token(), Some("tok"));
assert_eq!(AuthConfig::default().issued_token(), None);
}
#[test]
fn authorizer_allow_all_permits_everything() {
let authz = Authorizer::AllowAll;
assert!(authz.is_allowed(&Identity::Token("anyone".into()), "q1"));
assert!(authz.is_allowed(&Identity::Anonymous, "q2"));
assert!(
authz
.authorize(&Identity::Basic("u".into()), "anything")
.is_ok()
);
}
#[test]
fn authorizer_allow_list_allows_and_denies_per_identity() {
let authz = Authorizer::allow_list([("analyst", vec!["q1"])]);
let analyst = Identity::Token("analyst".into());
assert!(authz.is_allowed(&analyst, "q1"));
assert!(!authz.is_allowed(&analyst, "q2"));
assert!(authz.authorize(&analyst, "q1").is_ok());
let denied = authz.authorize(&analyst, "q2").unwrap_err();
assert_eq!(denied.code(), tonic::Code::PermissionDenied);
assert!(!authz.is_allowed(&Identity::Token("stranger".into()), "q1"));
}
#[test]
fn authorizer_allow_list_wildcard_fallback() {
let authz = Authorizer::allow_list([("analyst", vec!["q1"]), ("*", vec!["q_public"])]);
let analyst = Identity::Token("analyst".into());
assert!(authz.is_allowed(&analyst, "q1"));
assert!(authz.is_allowed(&analyst, "q_public")); let other = Identity::Token("other".into());
assert!(authz.is_allowed(&other, "q_public"));
assert!(!authz.is_allowed(&other, "q1"));
}
#[test]
fn authorizer_custom_closure() {
let authz = Authorizer::custom(|id, q| id.name() == "root" || q == "q_open");
assert!(authz.is_allowed(&Identity::Token("root".into()), "anything"));
assert!(authz.is_allowed(&Identity::Token("nobody".into()), "q_open"));
assert!(!authz.is_allowed(&Identity::Token("nobody".into()), "q_secret"));
}
}