use super::backend::{AuthBackend, AuthError, Principal, Role};
use super::session_backend::SessionAuthBackend;
use async_trait::async_trait;
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use pensieve_core::catalog::Catalog;
use pensieve_core::tenant::DEFAULT_TENANT;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
#[derive(Debug, Clone)]
pub struct SupabaseAuthConfig {
pub url: String,
pub jwt_secret: Option<String>,
pub audience: String,
pub admin_emails: Vec<String>,
pub allowed_email_domains: Vec<String>,
pub default_role: Role,
}
impl SupabaseAuthConfig {
pub fn from_env() -> Option<Self> {
let url = std::env::var("PENSIEVE_SUPABASE_URL").ok()?;
let csv = |key: &str| -> Vec<String> {
std::env::var(key)
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect()
};
Some(Self {
url: url.trim_end_matches('/').to_string(),
jwt_secret: std::env::var("PENSIEVE_SUPABASE_JWT_SECRET")
.ok()
.filter(|s| !s.is_empty()),
audience: std::env::var("PENSIEVE_SUPABASE_JWT_AUD")
.unwrap_or_else(|_| "authenticated".to_string()),
admin_emails: csv("PENSIEVE_ADMIN_EMAILS"),
allowed_email_domains: csv("PENSIEVE_ALLOWED_EMAIL_DOMAINS"),
default_role: std::env::var("PENSIEVE_SUPABASE_DEFAULT_ROLE")
.ok()
.and_then(|v| Role::parse(&v))
.unwrap_or(Role::Read),
})
}
}
#[derive(serde::Deserialize)]
struct Claims {
sub: String,
email: Option<String>,
#[serde(default)]
app_metadata: serde_json::Value,
}
#[derive(Default)]
struct JwksCache {
keys: HashMap<String, DecodingKey>,
fetched_at: Option<Instant>,
}
pub struct SupabaseAuthBackend {
config: SupabaseAuthConfig,
catalog: Arc<dyn Catalog>,
inner: SessionAuthBackend,
http: reqwest::Client,
jwks: RwLock<JwksCache>,
upserted: RwLock<HashMap<String, Instant>>,
}
fn is_jwt_shaped(token: &str) -> bool {
token.starts_with("eyJ") && token.bytes().filter(|b| *b == b'.').count() == 2
}
impl SupabaseAuthBackend {
pub fn new(
config: SupabaseAuthConfig,
catalog: Arc<dyn Catalog>,
inner: SessionAuthBackend,
) -> Self {
Self {
config,
catalog,
inner,
http: reqwest::Client::new(),
jwks: RwLock::new(JwksCache::default()),
upserted: RwLock::new(HashMap::new()),
}
}
async fn decoding_key(
&self,
header: &jsonwebtoken::Header,
) -> Result<DecodingKey, AuthError> {
if let (Some(secret), Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512) =
(&self.config.jwt_secret, header.alg)
{
return Ok(DecodingKey::from_secret(secret.as_bytes()));
}
let kid = header.kid.as_deref().ok_or(AuthError::UnknownToken)?;
self.jwks_key(kid).await
}
async fn jwks_key(&self, kid: &str) -> Result<DecodingKey, AuthError> {
if let Some(key) = self.jwks.read().await.keys.get(kid) {
return Ok(key.clone());
}
let mut cache = self.jwks.write().await;
if let Some(key) = cache.keys.get(kid) {
return Ok(key.clone());
}
let due = cache
.fetched_at
.is_none_or(|at| at.elapsed() >= REFRESH_INTERVAL);
if due {
let url = format!("{}/auth/v1/.well-known/jwks.json", self.config.url);
let set: jsonwebtoken::jwk::JwkSet = self
.http
.get(&url)
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|e| AuthError::Backend(format!("jwks fetch failed: {e}")))?
.json()
.await
.map_err(|e| AuthError::Backend(format!("jwks parse failed: {e}")))?;
let mut keys = HashMap::new();
for jwk in &set.keys {
if let (Some(id), Ok(key)) =
(jwk.common.key_id.clone(), DecodingKey::from_jwk(jwk))
{
keys.insert(id, key);
}
}
cache.keys = keys;
cache.fetched_at = Some(Instant::now());
}
cache.keys.get(kid).cloned().ok_or(AuthError::UnknownToken)
}
fn resolve_role(&self, email: Option<&str>, claims: &Claims) -> Result<Role, AuthError> {
if !self.config.allowed_email_domains.is_empty() {
let allowed = email
.and_then(|e| e.rsplit_once('@'))
.map(|(_, domain)| domain.to_lowercase())
.is_some_and(|d| self.config.allowed_email_domains.contains(&d));
if !allowed {
return Err(AuthError::UnknownToken);
}
}
if let Some(email) = email {
if self
.config
.admin_emails
.iter()
.any(|a| a.eq_ignore_ascii_case(email))
{
return Ok(Role::Admin);
}
}
if let Some(role) = claims
.app_metadata
.get("role")
.and_then(|v| v.as_str())
.and_then(Role::parse)
{
return Ok(role);
}
Ok(self.config.default_role)
}
async fn authenticate_jwt(&self, token: &str) -> Result<Principal, AuthError> {
let header = decode_header(token).map_err(|_| AuthError::UnknownToken)?;
let key = self.decoding_key(&header).await?;
let mut validation = Validation::new(header.alg);
validation.leeway = 60;
validation.set_audience(&[&self.config.audience]);
validation.set_issuer(&[format!("{}/auth/v1", self.config.url)]);
let data =
decode::<Claims>(token, &key, &validation).map_err(|_| AuthError::UnknownToken)?;
let claims = data.claims;
let role = self.resolve_role(claims.email.as_deref(), &claims)?;
let username = claims.email.clone().unwrap_or_else(|| claims.sub.clone());
let due = {
let seen = self.upserted.read().await;
seen.get(&claims.sub)
.is_none_or(|at| at.elapsed() >= REFRESH_INTERVAL)
};
if due {
let role_str = match role {
Role::Admin => "admin",
Role::Write => "write",
Role::Read => "read",
};
self.catalog
.upsert_external_user_in_tenant(
DEFAULT_TENANT,
"supabase",
&claims.sub,
&username,
role_str,
)
.await
.map_err(|e| AuthError::Backend(e.to_string()))?;
self.upserted
.write()
.await
.insert(claims.sub.clone(), Instant::now());
}
Ok(Principal {
tenant: DEFAULT_TENANT,
role,
subject: Some(username),
allowed_databases: None,
allowed_realms: None,
})
}
}
#[async_trait]
impl AuthBackend for SupabaseAuthBackend {
fn enabled(&self) -> bool {
true
}
async fn authenticate(&self, token: &str) -> Result<Principal, AuthError> {
if is_jwt_shaped(token) {
match self.authenticate_jwt(token).await {
Ok(p) => return Ok(p),
Err(AuthError::UnknownToken) => {}
Err(other) => return Err(other),
}
}
self.inner.authenticate(token).await
}
}