use crate::supabase::jwt::{self, Claims, JwtError};
use chrono::{DateTime, Utc};
pub const API_KEY_TTL_SECS: i64 = 10 * 365 * 24 * 60 * 60;
#[derive(Clone, PartialEq, Eq)]
pub struct Secret(String);
impl Secret {
pub fn new(value: impl Into<String>) -> Self {
Secret(value.into())
}
pub fn expose(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Secret(***redacted***)")
}
}
impl From<String> for Secret {
fn from(s: String) -> Self {
Secret(s)
}
}
#[derive(Clone)]
pub struct ProjectKeys {
pub jwt_secret: Secret,
pub anon_key: String,
pub service_role_key: String,
}
impl std::fmt::Debug for ProjectKeys {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProjectKeys")
.field("jwt_secret", &self.jwt_secret)
.field("anon_key", &"***redacted***")
.field("service_role_key", &"***redacted***")
.finish()
}
}
impl ProjectKeys {
pub fn from_secret(secret: impl Into<String>, iat: i64) -> Result<Self, JwtError> {
let secret = secret.into();
let exp = iat + API_KEY_TTL_SECS;
let anon_key = jwt::sign(&Claims::api_key("anon", iat, exp), &secret)?;
let service_role_key = jwt::sign(&Claims::api_key("service_role", iat, exp), &secret)?;
Ok(Self {
jwt_secret: Secret::new(secret),
anon_key,
service_role_key,
})
}
pub fn generate(iat: i64) -> Result<Self, JwtError> {
Self::from_secret(generate_jwt_secret(), iat)
}
pub fn verify_api_key(&self, token: &str, now: i64) -> Result<Claims, JwtError> {
jwt::verify(token, self.jwt_secret.expose(), now)
}
}
pub fn generate_jwt_secret() -> String {
fastrand::seed(seed_from_entropy());
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
(0..48)
.map(|_| ALPHABET[fastrand::usize(..ALPHABET.len())] as char)
.collect()
}
fn seed_from_entropy() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let uuid = uuid::Uuid::new_v4();
let (hi, lo) = uuid.as_u64_pair();
nanos ^ hi ^ lo.rotate_left(17)
}
#[derive(Debug, Clone)]
pub struct SupabaseCompatProject {
pub id: String,
pub project_ref: String,
pub tenant_id: String,
pub name: String,
pub db_name: String,
pub api_url: String,
pub db_url: Option<String>,
pub keys: ProjectKeys,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl SupabaseCompatProject {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: impl Into<String>,
project_ref: impl Into<String>,
name: impl Into<String>,
db_name: impl Into<String>,
api_url: impl Into<String>,
db_url: Option<String>,
keys: ProjectKeys,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
) -> Self {
let project_ref = project_ref.into();
Self {
id: id.into(),
tenant_id: project_ref.clone(),
project_ref,
name: name.into(),
db_name: db_name.into(),
api_url: api_url.into(),
db_url,
keys,
created_at,
updated_at,
}
}
pub fn shell(
db_name: impl Into<String>,
api_url: impl Into<String>,
keys: ProjectKeys,
now: DateTime<Utc>,
) -> Self {
let db_name = db_name.into();
let project_ref = default_project_ref();
Self::new(
uuid::Uuid::new_v4().to_string(),
project_ref,
format!("guardian-{db_name}"),
db_name,
api_url,
None,
keys,
now,
now,
)
}
}
fn default_project_ref() -> String {
fastrand::seed(seed_from_entropy());
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
(0..20)
.map(|_| ALPHABET[fastrand::usize(..ALPHABET.len())] as char)
.collect()
}
#[derive(Debug, Clone)]
pub struct ServiceConfig {
pub site_url: String,
pub jwt_exp: i64,
pub refresh_token_ttl: i64,
pub jwt_aud: String,
pub disable_signup: bool,
}
impl Default for ServiceConfig {
fn default() -> Self {
Self {
site_url: "http://localhost:3000".to_string(),
jwt_exp: 3600,
refresh_token_ttl: 30 * 24 * 60 * 60,
jwt_aud: "authenticated".to_string(),
disable_signup: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::supabase::jwt;
#[test]
fn from_secret_is_deterministic() {
let a = ProjectKeys::from_secret("test-secret-abcdefghijklmnop", 1_700_000_000).unwrap();
let b = ProjectKeys::from_secret("test-secret-abcdefghijklmnop", 1_700_000_000).unwrap();
assert_eq!(a.anon_key, b.anon_key);
assert_eq!(a.service_role_key, b.service_role_key);
}
#[test]
fn generated_keys_have_supabase_claim_shape() {
let iat = 1_700_000_000;
let keys = ProjectKeys::from_secret("test-secret-abcdefghijklmnop", iat).unwrap();
let anon = jwt::verify(&keys.anon_key, keys.jwt_secret.expose(), iat).unwrap();
assert_eq!(anon.role, "anon");
assert_eq!(anon.iss.as_deref(), Some("supabase"));
assert_eq!(anon.iat, iat);
assert_eq!(anon.exp, iat + API_KEY_TTL_SECS);
assert!(anon.sub.is_none() && anon.email.is_none());
let service = jwt::verify(&keys.service_role_key, keys.jwt_secret.expose(), iat).unwrap();
assert_eq!(service.role, "service_role");
assert_eq!(service.pg_role(), "service_role");
}
#[test]
fn generate_secret_is_long_and_varied() {
let s1 = generate_jwt_secret();
let s2 = generate_jwt_secret();
assert!(s1.len() >= 40, "secret must be at least 40 chars");
assert!(s1.chars().all(|c| c.is_ascii_alphanumeric()));
assert_ne!(s1, s2, "two generated secrets must differ");
}
#[test]
fn secret_is_redacted_in_debug() {
let s = Secret::new("hunter2-super-secret");
let dumped = format!("{s:?}");
assert!(
!dumped.contains("hunter2"),
"secret leaked in Debug: {dumped}"
);
let keys = ProjectKeys::from_secret("hunter2-super-secret-value-here", 1).unwrap();
let dumped = format!("{keys:?}");
assert!(!dumped.contains("hunter2"));
assert!(!dumped.contains(&keys.anon_key));
}
#[test]
fn verify_api_key_round_trips() {
let iat = 1_700_000_000;
let keys = ProjectKeys::from_secret("test-secret-abcdefghijklmnop", iat).unwrap();
let claims = keys.verify_api_key(&keys.service_role_key, iat).unwrap();
assert_eq!(claims.role, "service_role");
assert!(keys.verify_api_key("not.a.jwt", iat).is_err());
}
}