use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, Mutex};
use anyhow::{anyhow, Context, Result};
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Header, Validation};
use serde::Deserialize;
use crate::auth::UserAccessConfig;
use crate::constants::rpc;
use crate::db_client::DatabaseConfig;
pub const CLIENT_ABILITIES_JWT_AUDIENCE: &str = "scout-client";
#[derive(Debug, Clone, Deserialize)]
pub struct ClientAbilitiesHerdClaim {
pub herd_id: i64,
pub slug: String,
#[serde(default)]
pub abilities: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ClientAbilitiesTokenMint {
pub token: String,
#[serde(default)]
pub platform_superadmin: bool,
#[serde(default)]
pub herds: Vec<ClientAbilitiesHerdClaim>,
pub iat: i64,
pub exp: i64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ClientAbilitiesJwkPublicKey {
pub kid: String,
pub jwk: serde_json::Value,
}
#[derive(Clone)]
pub struct ClientAbilitiesTokenConfig {
cache: Arc<Mutex<HashMap<String, DecodingKey>>>,
}
impl ClientAbilitiesTokenConfig {
pub fn from_public_jwks(jwks: &[&str]) -> Result<Self> {
let mut cache = HashMap::new();
for jwk_json in jwks {
let jwk: ClientAbilitiesJwk = serde_json::from_str(jwk_json)?;
let kid = jwk
.kid
.as_deref()
.ok_or_else(|| anyhow!("public JWK must include kid"))?;
cache.insert(kid.to_string(), decoding_key_from_jwk(&jwk)?);
}
Ok(Self {
cache: Arc::new(Mutex::new(cache)),
})
}
pub fn from_public_keys(keys: &[ClientAbilitiesJwkPublicKey]) -> Result<Self> {
let config = Self {
cache: Arc::new(Mutex::new(HashMap::new())),
};
config.load_public_keys(keys)?;
Ok(config)
}
pub fn sync_public_keys_from_database(&self, db: &DatabaseConfig) -> Result<()> {
self.load_public_keys(&fetch_client_abilities_jwt_public_keys(db)?)
}
fn load_public_keys(&self, keys: &[ClientAbilitiesJwkPublicKey]) -> Result<()> {
let mut loaded = HashMap::new();
for key in keys {
if !valid_kid(&key.kid) {
return Err(anyhow!("invalid client abilities JWT kid: {}", key.kid));
}
let jwk: ClientAbilitiesJwk = serde_json::from_value(key.jwk.clone())?;
loaded.insert(key.kid.clone(), decoding_key_from_jwk(&jwk)?);
}
*self.cache.lock().expect("client abilities key cache") = loaded;
Ok(())
}
fn resolve_key(&self, header: &Header) -> Result<DecodingKey, ClientAbilitiesTokenError> {
if header.alg != Algorithm::ES256 {
return Err(ClientAbilitiesTokenError::Invalid);
}
let kid = header
.kid
.as_deref()
.filter(|kid| valid_kid(kid))
.ok_or(ClientAbilitiesTokenError::Invalid)?;
self.cache
.lock()
.expect("client abilities key cache")
.get(kid)
.cloned()
.ok_or(ClientAbilitiesTokenError::Invalid)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ClientAbilitiesTokenClaims {
#[serde(rename = "sub")]
pub subject: String,
#[serde(default)]
pub platform_superadmin: bool,
#[serde(default)]
pub herds: Vec<ClientAbilitiesHerdClaim>,
pub iat: i64,
pub exp: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientAbilitiesTokenError {
Invalid,
Expired,
}
impl fmt::Display for ClientAbilitiesTokenError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ClientAbilitiesTokenError::Invalid => write!(f, "invalid client abilities token"),
ClientAbilitiesTokenError::Expired => write!(f, "expired client abilities token"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MintClientAbilitiesTokenError {
MissingUserAccessToken,
MissingSupabaseApiKey,
Unauthorized,
Forbidden,
Http { status: u16, body: String },
InvalidResponse,
}
impl fmt::Display for MintClientAbilitiesTokenError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MintClientAbilitiesTokenError::MissingUserAccessToken => {
write!(
f,
"user access token required to mint client abilities token"
)
}
MintClientAbilitiesTokenError::MissingSupabaseApiKey => {
write!(
f,
"supabase API key required to mint client abilities token"
)
}
MintClientAbilitiesTokenError::Unauthorized => write!(f, "unauthorized"),
MintClientAbilitiesTokenError::Forbidden => write!(f, "forbidden"),
MintClientAbilitiesTokenError::Http { status, body } => {
write!(
f,
"mint-client-abilities-token failed: HTTP {status} - {body}"
)
}
MintClientAbilitiesTokenError::InvalidResponse => {
write!(f, "mint-client-abilities-token returned invalid response")
}
}
}
}
pub fn fetch_client_abilities_jwt_public_keys(
db: &DatabaseConfig,
) -> Result<Vec<ClientAbilitiesJwkPublicKey>> {
let api_key = db
.service_role_key
.as_deref()
.filter(|_| db.use_service_key)
.unwrap_or_else(|| db.get_supabase_api_key());
let url = format!(
"{}/rpc/{}",
db.get_rest_url().trim_end_matches('/'),
rpc::GET_CLIENT_ABILITIES_JWT_PUBLIC_KEYS
);
let response = reqwest::blocking::Client::new()
.post(url)
.header("apikey", api_key)
.header("Authorization", format!("Bearer {api_key}"))
.header("Content-Type", "application/json")
.body("{}")
.send()
.context("get_client_abilities_jwt_public_keys request")?;
let status = response.status();
let body = response
.text()
.context("get_client_abilities_jwt_public_keys response body")?;
if !status.is_success() {
return Err(anyhow!(
"get_client_abilities_jwt_public_keys failed: HTTP {status} - {body}"
));
}
serde_json::from_str(&body).context("parse get_client_abilities_jwt_public_keys response")
}
pub fn verify_client_abilities_token(
config: &ClientAbilitiesTokenConfig,
token: &str,
) -> Result<ClientAbilitiesTokenClaims, ClientAbilitiesTokenError> {
let token = token.trim();
let header = decode_header(token).map_err(|_| ClientAbilitiesTokenError::Invalid)?;
let key = config.resolve_key(&header)?;
let mut validation = Validation::new(Algorithm::ES256);
validation.set_audience(&[CLIENT_ABILITIES_JWT_AUDIENCE]);
let data = decode::<ClientAbilitiesTokenClaims>(token, &key, &validation).map_err(|error| {
if matches!(
error.kind(),
jsonwebtoken::errors::ErrorKind::ExpiredSignature
) {
ClientAbilitiesTokenError::Expired
} else {
ClientAbilitiesTokenError::Invalid
}
})?;
Ok(data.claims)
}
pub fn mint_client_abilities_token(
config: &UserAccessConfig,
user_access_token: &str,
) -> Result<ClientAbilitiesTokenMint, MintClientAbilitiesTokenError> {
let token = user_access_token.trim();
if token.is_empty() {
return Err(MintClientAbilitiesTokenError::MissingUserAccessToken);
}
if config.supabase_anon_key.is_empty() {
return Err(MintClientAbilitiesTokenError::MissingSupabaseApiKey);
}
let url = format!(
"{}/mint-client-abilities-token",
functions_url(&config.rest_url)
);
let response = reqwest::blocking::Client::new()
.post(url)
.header("apikey", &config.supabase_anon_key)
.header("Authorization", format!("Bearer {token}"))
.send()
.map_err(|e| MintClientAbilitiesTokenError::Http {
status: 0,
body: e.to_string(),
})?;
let status = response.status();
let body = response
.text()
.map_err(|e| MintClientAbilitiesTokenError::Http {
status: status.as_u16(),
body: e.to_string(),
})?;
match status.as_u16() {
401 => Err(MintClientAbilitiesTokenError::Unauthorized),
403 => Err(MintClientAbilitiesTokenError::Forbidden),
_ if !status.is_success() => Err(MintClientAbilitiesTokenError::Http {
status: status.as_u16(),
body,
}),
_ => {
let mint: ClientAbilitiesTokenMint = serde_json::from_str(&body)
.map_err(|_| MintClientAbilitiesTokenError::InvalidResponse)?;
if mint.token.is_empty() {
Err(MintClientAbilitiesTokenError::InvalidResponse)
} else {
Ok(mint)
}
}
}
}
fn functions_url(rest_url: &str) -> String {
let rest = rest_url.trim_end_matches('/');
let base = rest.strip_suffix("/rest/v1").unwrap_or(rest);
format!("{base}/functions/v1")
}
#[derive(Debug, Deserialize)]
struct ClientAbilitiesJwk {
kid: Option<String>,
kty: String,
#[serde(rename = "crv")]
curve: Option<String>,
alg: Option<String>,
x: Option<String>,
y: Option<String>,
#[serde(rename = "d")]
private_scalar: Option<String>,
}
fn decoding_key_from_jwk(jwk: &ClientAbilitiesJwk) -> Result<DecodingKey> {
if jwk.private_scalar.is_some() {
return Err(anyhow!("public JWK must not include d"));
}
if jwk.kty != "EC"
|| jwk.curve.as_deref() != Some("P-256")
|| jwk.alg.as_deref().is_some_and(|alg| alg != "ES256")
{
return Err(anyhow!("client abilities JWK must use ES256"));
}
DecodingKey::from_ec_components(
jwk.x.as_ref().context("EC JWK missing x")?,
jwk.y.as_ref().context("EC JWK missing y")?,
)
.context("invalid client abilities JWK")
}
fn valid_kid(kid: &str) -> bool {
!kid.is_empty()
&& kid
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}