Service-to-Service Authentication
Internal worker-to-worker communication uses HMAC-SHA256 signed JWT tokens for secure authentication.
Key Files
crates/clerk-auth/src/service_auth.rs- Token creation and verificationcrates/clerk-auth/src/middleware.rs- ServiceAuthMiddlewarecrates/bf_types/src/mesh/client.rs- Typed service clients
Architecture Overview
┌─────────────────────────────────────────────────────────────────────────┐
│ bf_notify Worker │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Received SmartThings webhook, need to lookup property_id │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ ServiceAuthenticator │ │
│ │ │ │
│ │ let token = authenticator.create_token("bf_notify")?; │ │
│ │ │ │
│ │ Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... │ │
│ │ Claims: { service: "bf_notify", iat, exp, jti } │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
│
│ POST /service/lookup
│ Authorization: Bearer {token}
│ X-Service-Name: bf_notify
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ bf_resident Worker │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ ServiceAuthMiddleware │ │
│ │ │ │
│ │ 1. Extract token from Authorization header │ │
│ │ 2. Verify HMAC-SHA256 signature (SERVICE_AUTH_SECRET) │ │
│ │ 3. Validate expiration (5 minute TTL) │ │
│ │ 4. Check service name is in allowed list │ │
│ │ 5. Add ServiceAuth to request extensions │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Token Format
JWT Claims
// crates/clerk-auth/src/service_auth.rs
#[derive(Debug, Serialize, Deserialize)]
pub struct ServiceClaims {
pub service: String, // Calling service name
pub iat: u64, // Issued at (Unix timestamp)
pub exp: u64, // Expiration (iat + 300 seconds)
pub jti: String, // JWT ID (request ID for replay protection)
}
Example Token Payload
{
"service": "bf_notify",
"iat": 1705123456,
"exp": 1705123756,
"jti": "req_a1b2c3d4e5f6"
}
Security Properties
| Property | Value | Purpose |
|---|---|---|
| Algorithm | HS256 | HMAC-SHA256 signing |
| TTL | 5 minutes | Limits blast radius if compromised |
| JTI | UUID | Enables replay detection |
| Secret | SERVICE_AUTH_SECRET | Shared secret across workers |
Valid Service Names
pub const SERVICE_NAMES: &[&str] = &[
"bf_resident",
"bf_notify",
"bf_smart",
"bf_storage",
"bf_user",
"bf_email_forwarder",
"bf_mobile",
"dev", // Development only
];
ServiceAuthenticator
Creating Tokens
// crates/clerk-auth/src/service_auth.rs
pub struct ServiceAuthenticator {
secret: String,
}
impl ServiceAuthenticator {
pub fn new(secret: String) -> Self {
Self { secret }
}
pub fn create_token(&self, service_name: &str) -> Result<String, ServiceAuthError> {
// Validate service name
if !SERVICE_NAMES.contains(&service_name) {
return Err(ServiceAuthError::InvalidServiceName(service_name.to_string()));
}
let now = current_timestamp_secs()?;
let claims = ServiceClaims {
service: service_name.to_string(),
iat: now,
exp: now + 300, // 5 minutes
jti: Self::generate_request_id(),
};
let token = encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(self.secret.as_bytes()),
)?;
Ok(token)
}
fn generate_request_id() -> String {
format!("req_{}", uuid::Uuid::new_v4().to_string().replace("-", "")[..12])
}
}
Verifying Tokens
impl ServiceAuthenticator {
pub fn verify_token(&self, token: &str) -> Result<ServiceClaims, ServiceAuthError> {
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = true;
validation.set_required_spec_claims(&["service", "iat", "exp", "jti"]);
let token_data = decode::<ServiceClaims>(
token,
&DecodingKey::from_secret(self.secret.as_bytes()),
&validation,
)?;
let claims = token_data.claims;
// Verify service name is valid
if !SERVICE_NAMES.contains(&claims.service.as_str()) {
return Err(ServiceAuthError::InvalidServiceName(claims.service));
}
Ok(claims)
}
}
ServiceAuthMiddleware
// crates/clerk-auth/src/middleware.rs
pub struct ServiceAuthMiddleware {
authenticator: ServiceAuthenticator,
}
impl<S, B> Service<Request<B>> for ServiceAuthService<S> {
async fn call(&self, mut request: Request<B>) -> Self::Response {
// Extract token from header
let token = match get_header(&request, "Authorization") {
Some(h) if h.starts_with("Bearer ") => h.replace("Bearer ", ""),
Some(h) if h.starts_with("X-Service-Auth ") => h.replace("X-Service-Auth ", ""),
_ => return unauthorized("Missing service authentication"),
};
// Verify token
let claims = match self.authenticator.verify_token(&token) {
Ok(c) => c,
Err(e) => return unauthorized(&format!("Invalid service token: {}", e)),
};
// Add to request extensions
let auth = ServiceAuth(claims);
request.extensions_mut().insert(auth);
self.inner.call(request).await
}
}
Mesh Client Pattern
Type-safe service-to-service calls with automatic token management.
Mesh Client
// crates/bf_types/src/mesh/client.rs
pub struct Mesh {
service_name: String,
authenticator: Arc<ServiceAuthenticator>,
urls: ServiceUrls,
client: reqwest::Client,
}
pub struct ServiceUrls {
pub resident: String,
pub smart: String,
pub notify: String,
}
impl Mesh {
pub fn new(service_name: &str, env: &Env) -> Result<Self, MeshError> {
let secret = env.var("SERVICE_AUTH_SECRET")?.to_string();
let authenticator = ServiceAuthenticator::new(secret);
let urls = ServiceUrls {
resident: env.var("BF_RESIDENT_URL")?.to_string(),
smart: env.var("BF_SMART_URL")?.to_string(),
notify: env.var("BF_NOTIFY_URL")?.to_string(),
};
Ok(Self {
service_name: service_name.to_string(),
authenticator: Arc::new(authenticator),
urls,
client: reqwest::Client::new(),
})
}
async fn request<R: DeserializeOwned>(
&self,
method: Method,
url: &str,
body: Option<&impl Serialize>,
) -> Result<R, MeshError> {
// Create fresh token for each request
let token = self.authenticator.create_token(&self.service_name)?;
let mut request = self.client
.request(method, url)
.header("Authorization", format!("Bearer {}", token))
.header("X-Service-Name", &self.service_name);
if let Some(body) = body {
request = request.json(body);
}
let response = request.send().await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(MeshError::RequestFailed { status, body: text });
}
response.json().await.map_err(MeshError::from)
}
}
Typed Service Clients
// Resident service operations
pub struct ResidentClient<'a> {
mesh: &'a Mesh,
}
impl<'a> ResidentClient<'a> {
pub async fn lookup_by_smartthings_ids(
&self,
request: &LookupBySmartthingsIdsRequest,
) -> Result<LookupByExternalIdResponse, MeshError> {
let url = format!("{}/service/lookup/smartthings", self.mesh.urls.resident);
self.mesh.post(&url, request).await
}
pub async fn lookup_by_yale_id(
&self,
request: &LookupByYaleIdRequest,
) -> Result<LookupByExternalIdResponse, MeshError> {
let url = format!("{}/service/lookup/yale", self.mesh.urls.resident);
self.mesh.post(&url, request).await
}
pub async fn sync_property_to_do(
&self,
property_id: &str,
) -> Result<(), MeshError> {
let url = format!("{}/service/sync/{}", self.mesh.urls.resident, property_id);
self.mesh.post_empty(&url, &()).await
}
}
// Smart service operations
pub struct SmartClient<'a> {
mesh: &'a Mesh,
}
impl<'a> SmartClient<'a> {
pub async fn execute_capability(
&self,
request: &ExecuteCapabilityRequest,
) -> Result<DeviceState, MeshError> {
let url = format!("{}/service/execute", self.mesh.urls.smart);
self.mesh.post(&url, request).await
}
}
Usage Example
// In bf_notify webhook handler
async fn handle_smartthings_webhook(req: Request, env: Env) -> Result<Response> {
let event: SmartThingsEvent = req.json().await?;
// Create mesh client
let mesh = Mesh::new("bf_notify", &env)?;
// Lookup property from SmartThings IDs
let lookup_result = mesh.resident()
.lookup_by_smartthings_ids(&LookupBySmartthingsIdsRequest {
smartthings_property_id: event.location_id.clone(),
smartthings_device_id: event.device_id.clone(),
})
.await?;
// Queue event for processing
let queued_event = QueuedSmartEvent {
property_id: lookup_result.property_id,
device_id: lookup_result.device_id,
event: event.into(),
};
env.queue("SMART_EVENTS")?.send(&queued_event).await?;
Response::ok("OK")
}
Error Handling
ServiceAuthError
#[derive(Debug, thiserror::Error)]
pub enum ServiceAuthError {
#[error("Invalid service name: {0}")]
InvalidServiceName(String),
#[error("Token expired")]
TokenExpired,
#[error("Invalid signature")]
InvalidSignature,
#[error("Missing required claim: {0}")]
MissingClaim(String),
#[error("JWT error: {0}")]
JwtError(#[from] jsonwebtoken::errors::Error),
}
HTTP Error Responses
401 Unauthorized:
{
"error": "unauthorized",
"message": "Missing service authentication"
}
403 Forbidden:
{
"error": "forbidden",
"message": "Invalid service name: unknown_service"
}
Security Considerations
Short Token Lifetime
5-minute expiration limits the impact of token compromise:
- Intercepted tokens become useless quickly
- No need for token revocation infrastructure
- Fresh token created for each request
Request ID (JTI)
Each token has a unique JTI for:
- Request tracing across services
- Potential replay detection (if implemented)
- Debugging and logging correlation
Shared Secret Management
SERVICE_AUTH_SECRET must be:
- Same value across all workers
- Stored in Cloudflare Secrets Manager
- Rotated periodically (requires coordinated deployment)
See Also
- authentication.md - User authentication
- permissions.md - Role-based access control
- workers-architecture.md - Worker communication