use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LtiContextClaim {
pub id: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LtiResourceLinkClaim {
pub id: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ToolJwt {
pub client_id: String,
pub iss: String,
pub sub: String,
pub exp: i64,
pub iat: i64,
pub names_and_roles_endpoint_url: Option<String>,
pub platform_iss: String,
pub deep_link_claim_data: Option<String>,
pub email: Option<String>,
pub name: Option<String>,
#[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/deployment_id")]
pub deployment_id: String,
#[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/message_type")]
pub message_type: String,
#[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/roles")]
pub roles: Vec<String>,
#[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/context")]
pub context: Option<LtiContextClaim>,
#[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/resource_link")]
pub resource_link: Option<LtiResourceLinkClaim>,
}
#[cfg(test)]
mod tests {
use super::*;
use atomic_lti::jwt::{encode_using_store, decode_using_store};
use atomic_lti_test::helpers::MockKeyStore;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{Duration, Utc};
#[test]
fn test_tool_jwt_serialization() {
let tool_jwt = ToolJwt {
client_id: "test-client".to_string(),
iss: "https://tool.example.com".to_string(),
sub: "user123".to_string(),
exp: (Utc::now() + Duration::minutes(60)).timestamp(),
iat: Utc::now().timestamp(),
names_and_roles_endpoint_url: Some("https://lms.example.com/nrps".to_string()),
platform_iss: "https://lms.example.com".to_string(),
deep_link_claim_data: None,
email: Some("user@example.com".to_string()),
name: Some("Test User".to_string()),
deployment_id: "deployment-123".to_string(),
message_type: "LtiResourceLinkRequest".to_string(),
roles: vec!["http://purl.imsglobal.org/vocab/lis/v2/membership#Learner".to_string()],
context: Some(LtiContextClaim {
id: "course-456".to_string(),
}),
resource_link: Some(LtiResourceLinkClaim {
id: "link-789".to_string(),
}),
};
let json = serde_json::to_string(&tool_jwt).expect("Failed to serialize ToolJwt");
assert!(json.contains("https://purl.imsglobal.org/spec/lti/claim/deployment_id"));
assert!(json.contains("https://purl.imsglobal.org/spec/lti/claim/message_type"));
assert!(json.contains("https://purl.imsglobal.org/spec/lti/claim/roles"));
assert!(json.contains("https://purl.imsglobal.org/spec/lti/claim/context"));
assert!(json.contains("https://purl.imsglobal.org/spec/lti/claim/resource_link"));
}
#[test]
fn test_tool_jwt_deserialization() {
let json = r#"{
"client_id": "test-client",
"iss": "https://tool.example.com",
"sub": "user123",
"exp": 1234567890,
"iat": 1234567800,
"names_and_roles_endpoint_url": "https://lms.example.com/nrps",
"platform_iss": "https://lms.example.com",
"deep_link_claim_data": null,
"email": "user@example.com",
"name": "Test User",
"https://purl.imsglobal.org/spec/lti/claim/deployment_id": "deployment-123",
"https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiResourceLinkRequest",
"https://purl.imsglobal.org/spec/lti/claim/roles": ["http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"],
"https://purl.imsglobal.org/spec/lti/claim/context": {
"id": "course-456"
},
"https://purl.imsglobal.org/spec/lti/claim/resource_link": {
"id": "link-789"
}
}"#;
let tool_jwt: ToolJwt = serde_json::from_str(json).expect("Failed to deserialize ToolJwt");
assert_eq!(tool_jwt.client_id, "test-client");
assert_eq!(tool_jwt.deployment_id, "deployment-123");
assert_eq!(tool_jwt.message_type, "LtiResourceLinkRequest");
assert_eq!(tool_jwt.roles.len(), 1);
assert!(tool_jwt.context.is_some());
assert_eq!(tool_jwt.context.unwrap().id, "course-456");
assert!(tool_jwt.resource_link.is_some());
assert_eq!(tool_jwt.resource_link.unwrap().id, "link-789");
}
#[test]
fn test_tool_jwt_optional_fields() {
let json = r#"{
"client_id": "test-client",
"iss": "https://tool.example.com",
"sub": "user123",
"exp": 1234567890,
"iat": 1234567800,
"names_and_roles_endpoint_url": null,
"platform_iss": "https://lms.example.com",
"deep_link_claim_data": null,
"email": null,
"name": null,
"https://purl.imsglobal.org/spec/lti/claim/deployment_id": "deployment-123",
"https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiResourceLinkRequest",
"https://purl.imsglobal.org/spec/lti/claim/roles": [],
"https://purl.imsglobal.org/spec/lti/claim/context": null,
"https://purl.imsglobal.org/spec/lti/claim/resource_link": null
}"#;
let tool_jwt: ToolJwt = serde_json::from_str(json).expect("Failed to deserialize ToolJwt");
assert!(tool_jwt.names_and_roles_endpoint_url.is_none());
assert!(tool_jwt.deep_link_claim_data.is_none());
assert!(tool_jwt.email.is_none());
assert!(tool_jwt.name.is_none());
assert!(tool_jwt.context.is_none());
assert!(tool_jwt.resource_link.is_none());
}
#[test]
fn test_nested_claim_structs() {
let context = LtiContextClaim {
id: "context-123".to_string(),
};
let resource_link = LtiResourceLinkClaim {
id: "resource-456".to_string(),
};
let context_json = serde_json::to_string(&context).expect("Failed to serialize context");
let deserialized_context: LtiContextClaim = serde_json::from_str(&context_json)
.expect("Failed to deserialize context");
assert_eq!(deserialized_context.id, "context-123");
let resource_json = serde_json::to_string(&resource_link).expect("Failed to serialize resource_link");
let deserialized_resource: LtiResourceLinkClaim = serde_json::from_str(&resource_json)
.expect("Failed to deserialize resource_link");
assert_eq!(deserialized_resource.id, "resource-456");
}
#[test]
fn test_round_trip_serialization() {
let original = ToolJwt {
client_id: "round-trip-test".to_string(),
iss: "https://tool.example.com".to_string(),
sub: "user999".to_string(),
exp: 9999999999,
iat: 9999999000,
names_and_roles_endpoint_url: Some("https://lms.example.com/nrps".to_string()),
platform_iss: "https://lms.example.com".to_string(),
deep_link_claim_data: Some("deep-link-data".to_string()),
email: Some("roundtrip@example.com".to_string()),
name: Some("Round Trip User".to_string()),
deployment_id: "deployment-round".to_string(),
message_type: "LtiDeepLinkingRequest".to_string(),
roles: vec![
"http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor".to_string(),
"http://purl.imsglobal.org/vocab/lis/v2/membership#Learner".to_string(),
],
context: Some(LtiContextClaim {
id: "round-trip-context".to_string(),
}),
resource_link: Some(LtiResourceLinkClaim {
id: "round-trip-link".to_string(),
}),
};
let json = serde_json::to_string(&original).expect("Failed to serialize");
let deserialized: ToolJwt = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(deserialized.client_id, original.client_id);
assert_eq!(deserialized.iss, original.iss);
assert_eq!(deserialized.sub, original.sub);
assert_eq!(deserialized.exp, original.exp);
assert_eq!(deserialized.iat, original.iat);
assert_eq!(deserialized.names_and_roles_endpoint_url, original.names_and_roles_endpoint_url);
assert_eq!(deserialized.platform_iss, original.platform_iss);
assert_eq!(deserialized.deep_link_claim_data, original.deep_link_claim_data);
assert_eq!(deserialized.email, original.email);
assert_eq!(deserialized.name, original.name);
assert_eq!(deserialized.deployment_id, original.deployment_id);
assert_eq!(deserialized.message_type, original.message_type);
assert_eq!(deserialized.roles, original.roles);
assert_eq!(deserialized.context.as_ref().unwrap().id, original.context.as_ref().unwrap().id);
assert_eq!(deserialized.resource_link.as_ref().unwrap().id, original.resource_link.as_ref().unwrap().id);
}
#[tokio::test]
async fn test_jwt_encode_decode() {
let tool_jwt = ToolJwt {
client_id: "jwt-test-client".to_string(),
iss: "https://tool.example.com".to_string(),
sub: "user-encode-decode".to_string(),
exp: (Utc::now() + Duration::minutes(60)).timestamp(),
iat: Utc::now().timestamp(),
names_and_roles_endpoint_url: Some("https://lms.example.com/nrps".to_string()),
platform_iss: "https://lms.example.com".to_string(),
deep_link_claim_data: Some("test-deep-link-data".to_string()),
email: Some("jwttest@example.com".to_string()),
name: Some("JWT Test User".to_string()),
deployment_id: "deployment-jwt".to_string(),
message_type: "LtiResourceLinkRequest".to_string(),
roles: vec!["http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor".to_string()],
context: Some(LtiContextClaim {
id: "context-jwt".to_string(),
}),
resource_link: Some(LtiResourceLinkClaim {
id: "link-jwt".to_string(),
}),
};
let key_store = MockKeyStore::default();
let encoded_jwt = encode_using_store(&tool_jwt, &key_store)
.await
.expect("Failed to encode ToolJwt");
assert!(!encoded_jwt.is_empty());
let decoded = decode_using_store::<ToolJwt>(&encoded_jwt, &key_store)
.await
.expect("Failed to decode ToolJwt");
assert_eq!(decoded.claims.client_id, tool_jwt.client_id);
assert_eq!(decoded.claims.iss, tool_jwt.iss);
assert_eq!(decoded.claims.sub, tool_jwt.sub);
assert_eq!(decoded.claims.platform_iss, tool_jwt.platform_iss);
assert_eq!(decoded.claims.deployment_id, tool_jwt.deployment_id);
assert_eq!(decoded.claims.message_type, tool_jwt.message_type);
assert_eq!(decoded.claims.roles, tool_jwt.roles);
assert_eq!(decoded.claims.email, tool_jwt.email);
assert_eq!(decoded.claims.name, tool_jwt.name);
assert!(decoded.claims.context.is_some());
assert_eq!(decoded.claims.context.unwrap().id, "context-jwt");
assert!(decoded.claims.resource_link.is_some());
assert_eq!(decoded.claims.resource_link.unwrap().id, "link-jwt");
}
#[tokio::test]
async fn test_jwt_with_minimal_fields() {
let tool_jwt = ToolJwt {
client_id: "minimal-client".to_string(),
iss: "https://tool.example.com".to_string(),
sub: "user-minimal".to_string(),
exp: (Utc::now() + Duration::minutes(60)).timestamp(),
iat: Utc::now().timestamp(),
names_and_roles_endpoint_url: None,
platform_iss: "https://lms.example.com".to_string(),
deep_link_claim_data: None,
email: None,
name: None,
deployment_id: "deployment-minimal".to_string(),
message_type: "LtiResourceLinkRequest".to_string(),
roles: vec![],
context: None,
resource_link: None,
};
let key_store = MockKeyStore::default();
let encoded_jwt = encode_using_store(&tool_jwt, &key_store)
.await
.expect("Failed to encode minimal ToolJwt");
let decoded = decode_using_store::<ToolJwt>(&encoded_jwt, &key_store)
.await
.expect("Failed to decode minimal ToolJwt");
assert_eq!(decoded.claims.client_id, "minimal-client");
assert_eq!(decoded.claims.deployment_id, "deployment-minimal");
assert!(decoded.claims.email.is_none());
assert!(decoded.claims.name.is_none());
assert!(decoded.claims.context.is_none());
assert!(decoded.claims.resource_link.is_none());
}
#[tokio::test]
async fn test_jwt_with_different_key_store_fails() {
let tool_jwt = ToolJwt {
client_id: "key-test-client".to_string(),
iss: "https://tool.example.com".to_string(),
sub: "user-key-test".to_string(),
exp: (Utc::now() + Duration::minutes(60)).timestamp(),
iat: Utc::now().timestamp(),
names_and_roles_endpoint_url: None,
platform_iss: "https://lms.example.com".to_string(),
deep_link_claim_data: None,
email: None,
name: None,
deployment_id: "deployment-key".to_string(),
message_type: "LtiResourceLinkRequest".to_string(),
roles: vec![],
context: None,
resource_link: None,
};
let key_store_1 = MockKeyStore::default();
let encoded_jwt = encode_using_store(&tool_jwt, &key_store_1)
.await
.expect("Failed to encode ToolJwt");
let key_store_2 = MockKeyStore::default();
let result = decode_using_store::<ToolJwt>(&encoded_jwt, &key_store_2).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_backward_compatibility_with_old_format() {
let old_format_json = r#"{
"client_id": "old-format-client",
"iss": "https://tool.example.com",
"sub": "user-old",
"exp": 9999999999,
"iat": 9999999000,
"names_and_roles_endpoint_url": null,
"platform_iss": "https://lms.example.com",
"deep_link_claim_data": null,
"email": "oldformat@example.com",
"name": "Old Format User",
"deployment_id": "deployment-old",
"message_type": "LtiResourceLinkRequest",
"roles": ["http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"],
"context": null,
"resource_link": null
}"#;
let result: Result<ToolJwt, _> = serde_json::from_str(old_format_json);
assert!(result.is_err(), "Old format without claim URIs should fail to deserialize");
}
#[tokio::test]
async fn test_new_format_with_full_claim_uris() {
let new_format_json = r#"{
"client_id": "new-format-client",
"iss": "https://tool.example.com",
"sub": "user-new",
"exp": 9999999999,
"iat": 9999999000,
"names_and_roles_endpoint_url": "https://lms.example.com/nrps",
"platform_iss": "https://lms.example.com",
"deep_link_claim_data": null,
"email": "newformat@example.com",
"name": "New Format User",
"https://purl.imsglobal.org/spec/lti/claim/deployment_id": "deployment-new",
"https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiResourceLinkRequest",
"https://purl.imsglobal.org/spec/lti/claim/roles": ["http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"],
"https://purl.imsglobal.org/spec/lti/claim/context": {
"id": "context-new"
},
"https://purl.imsglobal.org/spec/lti/claim/resource_link": {
"id": "link-new"
}
}"#;
let tool_jwt: ToolJwt = serde_json::from_str(new_format_json)
.expect("New format with full claim URIs should deserialize successfully");
assert_eq!(tool_jwt.client_id, "new-format-client");
assert_eq!(tool_jwt.deployment_id, "deployment-new");
assert_eq!(tool_jwt.message_type, "LtiResourceLinkRequest");
assert_eq!(tool_jwt.email, Some("newformat@example.com".to_string()));
assert_eq!(tool_jwt.name, Some("New Format User".to_string()));
assert!(tool_jwt.context.is_some());
assert_eq!(tool_jwt.context.unwrap().id, "context-new");
assert!(tool_jwt.resource_link.is_some());
assert_eq!(tool_jwt.resource_link.unwrap().id, "link-new");
}
#[tokio::test]
async fn test_encode_produces_full_claim_uris() {
let tool_jwt = ToolJwt {
client_id: "uri-test-client".to_string(),
iss: "https://tool.example.com".to_string(),
sub: "user-uri-test".to_string(),
exp: (Utc::now() + Duration::minutes(60)).timestamp(),
iat: Utc::now().timestamp(),
names_and_roles_endpoint_url: None,
platform_iss: "https://lms.example.com".to_string(),
deep_link_claim_data: None,
email: Some("uritest@example.com".to_string()),
name: Some("URI Test User".to_string()),
deployment_id: "deployment-uri".to_string(),
message_type: "LtiResourceLinkRequest".to_string(),
roles: vec!["http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor".to_string()],
context: Some(LtiContextClaim {
id: "context-uri".to_string(),
}),
resource_link: Some(LtiResourceLinkClaim {
id: "link-uri".to_string(),
}),
};
let key_store = MockKeyStore::default();
let encoded_jwt = encode_using_store(&tool_jwt, &key_store)
.await
.expect("Failed to encode ToolJwt");
let parts: Vec<&str> = encoded_jwt.split('.').collect();
assert_eq!(parts.len(), 3, "JWT should have 3 parts");
let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1])
.expect("Failed to decode JWT payload");
let payload_json = String::from_utf8(payload_bytes)
.expect("Failed to convert payload to string");
assert!(payload_json.contains("https://purl.imsglobal.org/spec/lti/claim/deployment_id"));
assert!(payload_json.contains("https://purl.imsglobal.org/spec/lti/claim/message_type"));
assert!(payload_json.contains("https://purl.imsglobal.org/spec/lti/claim/roles"));
assert!(payload_json.contains("https://purl.imsglobal.org/spec/lti/claim/context"));
assert!(payload_json.contains("https://purl.imsglobal.org/spec/lti/claim/resource_link"));
}
}