use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use tonic::Status;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DkodClaims {
pub sub: String,
pub iss: String,
pub exp: usize,
pub iat: usize,
pub scope: String,
}
#[derive(Clone, Debug)]
pub enum AuthConfig {
Jwt { secret: String },
SharedSecret { token: String },
Dual {
jwt_secret: String,
shared_token: String,
},
External,
}
impl AuthConfig {
pub fn validate(&self, token: &str) -> Result<String, Status> {
if token.is_empty() {
return Err(Status::unauthenticated("Auth token must not be empty"));
}
match self {
AuthConfig::Jwt { secret } => validate_jwt(token, secret),
AuthConfig::SharedSecret {
token: expected_token,
} => {
if token == expected_token {
Ok("anonymous".to_string())
} else {
Err(Status::unauthenticated("Invalid auth token"))
}
}
AuthConfig::Dual {
jwt_secret,
shared_token,
} => {
match validate_jwt(token, jwt_secret) {
Ok(agent_id) => Ok(agent_id),
Err(_jwt_err) => {
if token == shared_token {
Ok("anonymous".to_string())
} else {
Err(Status::unauthenticated("Invalid auth token"))
}
}
}
}
AuthConfig::External => {
Ok(token.to_string())
}
}
}
pub fn issue_token(
&self,
agent_id: &str,
scope: &str,
ttl_secs: usize,
) -> Result<String, Status> {
let secret = match self {
AuthConfig::Jwt { secret } => secret,
AuthConfig::Dual { jwt_secret, .. } => jwt_secret,
AuthConfig::SharedSecret { .. } | AuthConfig::External => {
return Err(Status::failed_precondition(
"Cannot issue JWT tokens without a JWT secret",
));
}
};
if secret.len() < 32 {
tracing::error!("JWT secret is too short (< 32 bytes); check server configuration");
return Err(Status::internal("server misconfiguration"));
}
let now = jsonwebtoken::get_current_timestamp() as usize;
let claims = DkodClaims {
sub: agent_id.to_string(),
iss: "dkod".to_string(),
exp: now + ttl_secs,
iat: now,
scope: scope.to_string(),
};
encode(
&Header::default(), &claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|e| Status::internal(format!("Failed to encode JWT: {e}")))
}
}
fn validate_jwt(token: &str, secret: &str) -> Result<String, Status> {
if secret.len() < 32 {
tracing::error!("JWT secret is too short (< 32 bytes); check server configuration");
return Err(Status::unauthenticated("JWT validation failed"));
}
let mut validation = Validation::new(jsonwebtoken::Algorithm::HS256);
validation.set_issuer(&["dkod"]);
validation.set_required_spec_claims(&["sub", "exp", "iss"]);
let token_data = decode::<DkodClaims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
)
.map_err(|e| Status::unauthenticated(format!("JWT validation failed: {e}")))?;
Ok(token_data.claims.sub)
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_SECRET: &str = "test-secret-key-for-unit-tests!!";
const TEST_AGENT: &str = "agent-42";
const TEST_SCOPE: &str = "read+write";
const TTL: usize = 3600;
#[test]
fn jwt_roundtrip() {
let config = AuthConfig::Jwt {
secret: TEST_SECRET.to_string(),
};
let token = config
.issue_token(TEST_AGENT, TEST_SCOPE, TTL)
.expect("issue_token should succeed");
let agent_id = config.validate(&token).expect("validate should succeed");
assert_eq!(agent_id, TEST_AGENT);
}
#[test]
fn jwt_rejects_bad_token() {
let config = AuthConfig::Jwt {
secret: TEST_SECRET.to_string(),
};
let result = config.validate("not-a-jwt");
assert!(result.is_err(), "should reject garbage token");
}
#[test]
fn jwt_rejects_wrong_secret() {
let config1 = AuthConfig::Jwt {
secret: "secret-one-padding-for-32-bytes!".to_string(),
};
let config2 = AuthConfig::Jwt {
secret: "secret-two-padding-for-32-bytes!".to_string(),
};
let token = config1
.issue_token(TEST_AGENT, TEST_SCOPE, TTL)
.expect("issue_token should succeed");
let result = config2.validate(&token);
assert!(result.is_err(), "should reject token signed with different secret");
}
#[test]
fn shared_secret_accepts_correct_token() {
let config = AuthConfig::SharedSecret {
token: "my-shared-token".to_string(),
};
let agent_id = config
.validate("my-shared-token")
.expect("should accept correct token");
assert_eq!(agent_id, "anonymous");
}
#[test]
fn shared_secret_rejects_wrong_token() {
let config = AuthConfig::SharedSecret {
token: "correct-token".to_string(),
};
let result = config.validate("wrong-token");
assert!(result.is_err(), "should reject wrong token");
}
#[test]
fn dual_mode_accepts_jwt() {
let config = AuthConfig::Dual {
jwt_secret: TEST_SECRET.to_string(),
shared_token: "fallback-token".to_string(),
};
let token = config
.issue_token(TEST_AGENT, TEST_SCOPE, TTL)
.expect("issue_token should succeed");
let agent_id = config.validate(&token).expect("should accept valid JWT");
assert_eq!(agent_id, TEST_AGENT);
}
#[test]
fn dual_mode_falls_back_to_shared_secret() {
let config = AuthConfig::Dual {
jwt_secret: TEST_SECRET.to_string(),
shared_token: "fallback-token".to_string(),
};
let agent_id = config
.validate("fallback-token")
.expect("should fall back to shared secret");
assert_eq!(agent_id, "anonymous");
}
#[test]
fn dual_mode_rejects_invalid() {
let config = AuthConfig::Dual {
jwt_secret: TEST_SECRET.to_string(),
shared_token: "fallback-token".to_string(),
};
let result = config.validate("garbage-that-matches-nothing");
assert!(result.is_err(), "should reject invalid token in dual mode");
}
#[test]
fn empty_token_rejected_in_all_modes() {
let jwt = AuthConfig::Jwt {
secret: TEST_SECRET.to_string(),
};
assert!(jwt.validate("").is_err(), "JWT mode should reject empty token");
let shared = AuthConfig::SharedSecret {
token: "my-token".to_string(),
};
assert!(shared.validate("").is_err(), "SharedSecret should reject empty token");
let dual = AuthConfig::Dual {
jwt_secret: TEST_SECRET.to_string(),
shared_token: "fallback".to_string(),
};
assert!(dual.validate("").is_err(), "Dual mode should reject empty token");
let external = AuthConfig::External;
assert!(external.validate("").is_err(), "External mode should reject empty token");
}
#[test]
fn empty_shared_secret_never_matches() {
let config = AuthConfig::SharedSecret {
token: "".to_string(),
};
assert!(config.validate("").is_err(), "empty token should be rejected even if shared secret is empty");
}
#[test]
fn external_passes_through_token_as_agent_id() {
let config = AuthConfig::External;
let agent_id = config
.validate("user_abc123")
.expect("External should accept any non-empty token");
assert_eq!(agent_id, "user_abc123");
}
#[test]
fn external_rejects_empty_token() {
let config = AuthConfig::External;
assert!(
config.validate("").is_err(),
"External should reject empty token"
);
}
#[test]
fn external_cannot_issue_tokens() {
let config = AuthConfig::External;
assert!(
config.issue_token("agent", "read", 3600).is_err(),
"External mode should not issue JWT tokens"
);
}
#[test]
fn rejects_short_jwt_secret() {
let config = AuthConfig::Jwt {
secret: "short".to_string(),
};
let result = config.issue_token("agent-1", "full", 3600);
assert!(result.is_err());
}
}