1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Deserialize, Serialize, Clone)]
8pub struct LtiContextClaim {
9 pub id: String,
11}
12
13#[derive(Debug, Deserialize, Serialize, Clone)]
18pub struct LtiResourceLinkClaim {
19 pub id: String,
21}
22
23#[derive(Debug, Deserialize, Serialize, Clone)]
61pub struct ToolJwt {
62 pub client_id: String,
64
65 pub iss: String,
67
68 pub sub: String,
70
71 pub exp: i64,
73
74 pub iat: i64,
76
77 pub names_and_roles_endpoint_url: Option<String>,
79
80 pub platform_iss: String,
82
83 pub deep_link_claim_data: Option<String>,
85
86 pub email: Option<String>,
88
89 pub name: Option<String>,
91
92 #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/deployment_id")]
98 pub deployment_id: String,
99
100 #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/message_type")]
104 pub message_type: String,
105
106 #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/roles")]
110 pub roles: Vec<String>,
111
112 #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/context")]
116 pub context: Option<LtiContextClaim>,
117
118 #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/resource_link")]
122 pub resource_link: Option<LtiResourceLinkClaim>,
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use atomic_lti::jwt::{encode_using_store, decode_using_store};
129 use atomic_lti_test::helpers::MockKeyStore;
130 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
131 use chrono::{Duration, Utc};
132
133 #[test]
134 fn test_tool_jwt_serialization() {
135 let tool_jwt = ToolJwt {
136 client_id: "test-client".to_string(),
137 iss: "https://tool.example.com".to_string(),
138 sub: "user123".to_string(),
139 exp: (Utc::now() + Duration::minutes(60)).timestamp(),
140 iat: Utc::now().timestamp(),
141 names_and_roles_endpoint_url: Some("https://lms.example.com/nrps".to_string()),
142 platform_iss: "https://lms.example.com".to_string(),
143 deep_link_claim_data: None,
144 email: Some("user@example.com".to_string()),
145 name: Some("Test User".to_string()),
146 deployment_id: "deployment-123".to_string(),
147 message_type: "LtiResourceLinkRequest".to_string(),
148 roles: vec!["http://purl.imsglobal.org/vocab/lis/v2/membership#Learner".to_string()],
149 context: Some(LtiContextClaim {
150 id: "course-456".to_string(),
151 }),
152 resource_link: Some(LtiResourceLinkClaim {
153 id: "link-789".to_string(),
154 }),
155 };
156
157 let json = serde_json::to_string(&tool_jwt).expect("Failed to serialize ToolJwt");
159
160 assert!(json.contains("https://purl.imsglobal.org/spec/lti/claim/deployment_id"));
162 assert!(json.contains("https://purl.imsglobal.org/spec/lti/claim/message_type"));
163 assert!(json.contains("https://purl.imsglobal.org/spec/lti/claim/roles"));
164 assert!(json.contains("https://purl.imsglobal.org/spec/lti/claim/context"));
165 assert!(json.contains("https://purl.imsglobal.org/spec/lti/claim/resource_link"));
166 }
167
168 #[test]
169 fn test_tool_jwt_deserialization() {
170 let json = r#"{
171 "client_id": "test-client",
172 "iss": "https://tool.example.com",
173 "sub": "user123",
174 "exp": 1234567890,
175 "iat": 1234567800,
176 "names_and_roles_endpoint_url": "https://lms.example.com/nrps",
177 "platform_iss": "https://lms.example.com",
178 "deep_link_claim_data": null,
179 "email": "user@example.com",
180 "name": "Test User",
181 "https://purl.imsglobal.org/spec/lti/claim/deployment_id": "deployment-123",
182 "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiResourceLinkRequest",
183 "https://purl.imsglobal.org/spec/lti/claim/roles": ["http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"],
184 "https://purl.imsglobal.org/spec/lti/claim/context": {
185 "id": "course-456"
186 },
187 "https://purl.imsglobal.org/spec/lti/claim/resource_link": {
188 "id": "link-789"
189 }
190 }"#;
191
192 let tool_jwt: ToolJwt = serde_json::from_str(json).expect("Failed to deserialize ToolJwt");
193
194 assert_eq!(tool_jwt.client_id, "test-client");
195 assert_eq!(tool_jwt.deployment_id, "deployment-123");
196 assert_eq!(tool_jwt.message_type, "LtiResourceLinkRequest");
197 assert_eq!(tool_jwt.roles.len(), 1);
198 assert!(tool_jwt.context.is_some());
199 assert_eq!(tool_jwt.context.unwrap().id, "course-456");
200 assert!(tool_jwt.resource_link.is_some());
201 assert_eq!(tool_jwt.resource_link.unwrap().id, "link-789");
202 }
203
204 #[test]
205 fn test_tool_jwt_optional_fields() {
206 let json = r#"{
208 "client_id": "test-client",
209 "iss": "https://tool.example.com",
210 "sub": "user123",
211 "exp": 1234567890,
212 "iat": 1234567800,
213 "names_and_roles_endpoint_url": null,
214 "platform_iss": "https://lms.example.com",
215 "deep_link_claim_data": null,
216 "email": null,
217 "name": null,
218 "https://purl.imsglobal.org/spec/lti/claim/deployment_id": "deployment-123",
219 "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiResourceLinkRequest",
220 "https://purl.imsglobal.org/spec/lti/claim/roles": [],
221 "https://purl.imsglobal.org/spec/lti/claim/context": null,
222 "https://purl.imsglobal.org/spec/lti/claim/resource_link": null
223 }"#;
224
225 let tool_jwt: ToolJwt = serde_json::from_str(json).expect("Failed to deserialize ToolJwt");
226
227 assert!(tool_jwt.names_and_roles_endpoint_url.is_none());
228 assert!(tool_jwt.deep_link_claim_data.is_none());
229 assert!(tool_jwt.email.is_none());
230 assert!(tool_jwt.name.is_none());
231 assert!(tool_jwt.context.is_none());
232 assert!(tool_jwt.resource_link.is_none());
233 }
234
235 #[test]
236 fn test_nested_claim_structs() {
237 let context = LtiContextClaim {
238 id: "context-123".to_string(),
239 };
240
241 let resource_link = LtiResourceLinkClaim {
242 id: "resource-456".to_string(),
243 };
244
245 let context_json = serde_json::to_string(&context).expect("Failed to serialize context");
247 let deserialized_context: LtiContextClaim = serde_json::from_str(&context_json)
248 .expect("Failed to deserialize context");
249 assert_eq!(deserialized_context.id, "context-123");
250
251 let resource_json = serde_json::to_string(&resource_link).expect("Failed to serialize resource_link");
252 let deserialized_resource: LtiResourceLinkClaim = serde_json::from_str(&resource_json)
253 .expect("Failed to deserialize resource_link");
254 assert_eq!(deserialized_resource.id, "resource-456");
255 }
256
257 #[test]
258 fn test_round_trip_serialization() {
259 let original = ToolJwt {
260 client_id: "round-trip-test".to_string(),
261 iss: "https://tool.example.com".to_string(),
262 sub: "user999".to_string(),
263 exp: 9999999999,
264 iat: 9999999000,
265 names_and_roles_endpoint_url: Some("https://lms.example.com/nrps".to_string()),
266 platform_iss: "https://lms.example.com".to_string(),
267 deep_link_claim_data: Some("deep-link-data".to_string()),
268 email: Some("roundtrip@example.com".to_string()),
269 name: Some("Round Trip User".to_string()),
270 deployment_id: "deployment-round".to_string(),
271 message_type: "LtiDeepLinkingRequest".to_string(),
272 roles: vec![
273 "http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor".to_string(),
274 "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner".to_string(),
275 ],
276 context: Some(LtiContextClaim {
277 id: "round-trip-context".to_string(),
278 }),
279 resource_link: Some(LtiResourceLinkClaim {
280 id: "round-trip-link".to_string(),
281 }),
282 };
283
284 let json = serde_json::to_string(&original).expect("Failed to serialize");
286
287 let deserialized: ToolJwt = serde_json::from_str(&json).expect("Failed to deserialize");
289
290 assert_eq!(deserialized.client_id, original.client_id);
292 assert_eq!(deserialized.iss, original.iss);
293 assert_eq!(deserialized.sub, original.sub);
294 assert_eq!(deserialized.exp, original.exp);
295 assert_eq!(deserialized.iat, original.iat);
296 assert_eq!(deserialized.names_and_roles_endpoint_url, original.names_and_roles_endpoint_url);
297 assert_eq!(deserialized.platform_iss, original.platform_iss);
298 assert_eq!(deserialized.deep_link_claim_data, original.deep_link_claim_data);
299 assert_eq!(deserialized.email, original.email);
300 assert_eq!(deserialized.name, original.name);
301 assert_eq!(deserialized.deployment_id, original.deployment_id);
302 assert_eq!(deserialized.message_type, original.message_type);
303 assert_eq!(deserialized.roles, original.roles);
304 assert_eq!(deserialized.context.as_ref().unwrap().id, original.context.as_ref().unwrap().id);
305 assert_eq!(deserialized.resource_link.as_ref().unwrap().id, original.resource_link.as_ref().unwrap().id);
306 }
307
308 #[tokio::test]
309 async fn test_jwt_encode_decode() {
310 let tool_jwt = ToolJwt {
312 client_id: "jwt-test-client".to_string(),
313 iss: "https://tool.example.com".to_string(),
314 sub: "user-encode-decode".to_string(),
315 exp: (Utc::now() + Duration::minutes(60)).timestamp(),
316 iat: Utc::now().timestamp(),
317 names_and_roles_endpoint_url: Some("https://lms.example.com/nrps".to_string()),
318 platform_iss: "https://lms.example.com".to_string(),
319 deep_link_claim_data: Some("test-deep-link-data".to_string()),
320 email: Some("jwttest@example.com".to_string()),
321 name: Some("JWT Test User".to_string()),
322 deployment_id: "deployment-jwt".to_string(),
323 message_type: "LtiResourceLinkRequest".to_string(),
324 roles: vec!["http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor".to_string()],
325 context: Some(LtiContextClaim {
326 id: "context-jwt".to_string(),
327 }),
328 resource_link: Some(LtiResourceLinkClaim {
329 id: "link-jwt".to_string(),
330 }),
331 };
332
333 let key_store = MockKeyStore::default();
335
336 let encoded_jwt = encode_using_store(&tool_jwt, &key_store)
338 .await
339 .expect("Failed to encode ToolJwt");
340
341 assert!(!encoded_jwt.is_empty());
342
343 let decoded = decode_using_store::<ToolJwt>(&encoded_jwt, &key_store)
345 .await
346 .expect("Failed to decode ToolJwt");
347
348 assert_eq!(decoded.claims.client_id, tool_jwt.client_id);
350 assert_eq!(decoded.claims.iss, tool_jwt.iss);
351 assert_eq!(decoded.claims.sub, tool_jwt.sub);
352 assert_eq!(decoded.claims.platform_iss, tool_jwt.platform_iss);
353 assert_eq!(decoded.claims.deployment_id, tool_jwt.deployment_id);
354 assert_eq!(decoded.claims.message_type, tool_jwt.message_type);
355 assert_eq!(decoded.claims.roles, tool_jwt.roles);
356 assert_eq!(decoded.claims.email, tool_jwt.email);
357 assert_eq!(decoded.claims.name, tool_jwt.name);
358
359 assert!(decoded.claims.context.is_some());
361 assert_eq!(decoded.claims.context.unwrap().id, "context-jwt");
362 assert!(decoded.claims.resource_link.is_some());
363 assert_eq!(decoded.claims.resource_link.unwrap().id, "link-jwt");
364 }
365
366 #[tokio::test]
367 async fn test_jwt_with_minimal_fields() {
368 let tool_jwt = ToolJwt {
370 client_id: "minimal-client".to_string(),
371 iss: "https://tool.example.com".to_string(),
372 sub: "user-minimal".to_string(),
373 exp: (Utc::now() + Duration::minutes(60)).timestamp(),
374 iat: Utc::now().timestamp(),
375 names_and_roles_endpoint_url: None,
376 platform_iss: "https://lms.example.com".to_string(),
377 deep_link_claim_data: None,
378 email: None,
379 name: None,
380 deployment_id: "deployment-minimal".to_string(),
381 message_type: "LtiResourceLinkRequest".to_string(),
382 roles: vec![],
383 context: None,
384 resource_link: None,
385 };
386
387 let key_store = MockKeyStore::default();
388
389 let encoded_jwt = encode_using_store(&tool_jwt, &key_store)
391 .await
392 .expect("Failed to encode minimal ToolJwt");
393
394 let decoded = decode_using_store::<ToolJwt>(&encoded_jwt, &key_store)
395 .await
396 .expect("Failed to decode minimal ToolJwt");
397
398 assert_eq!(decoded.claims.client_id, "minimal-client");
400 assert_eq!(decoded.claims.deployment_id, "deployment-minimal");
401
402 assert!(decoded.claims.email.is_none());
404 assert!(decoded.claims.name.is_none());
405 assert!(decoded.claims.context.is_none());
406 assert!(decoded.claims.resource_link.is_none());
407 }
408
409 #[tokio::test]
410 async fn test_jwt_with_different_key_store_fails() {
411 let tool_jwt = ToolJwt {
412 client_id: "key-test-client".to_string(),
413 iss: "https://tool.example.com".to_string(),
414 sub: "user-key-test".to_string(),
415 exp: (Utc::now() + Duration::minutes(60)).timestamp(),
416 iat: Utc::now().timestamp(),
417 names_and_roles_endpoint_url: None,
418 platform_iss: "https://lms.example.com".to_string(),
419 deep_link_claim_data: None,
420 email: None,
421 name: None,
422 deployment_id: "deployment-key".to_string(),
423 message_type: "LtiResourceLinkRequest".to_string(),
424 roles: vec![],
425 context: None,
426 resource_link: None,
427 };
428
429 let key_store_1 = MockKeyStore::default();
431 let encoded_jwt = encode_using_store(&tool_jwt, &key_store_1)
432 .await
433 .expect("Failed to encode ToolJwt");
434
435 let key_store_2 = MockKeyStore::default();
437 let result = decode_using_store::<ToolJwt>(&encoded_jwt, &key_store_2).await;
438
439 assert!(result.is_err());
440 }
441
442 #[tokio::test]
443 async fn test_backward_compatibility_with_old_format() {
444 let old_format_json = r#"{
447 "client_id": "old-format-client",
448 "iss": "https://tool.example.com",
449 "sub": "user-old",
450 "exp": 9999999999,
451 "iat": 9999999000,
452 "names_and_roles_endpoint_url": null,
453 "platform_iss": "https://lms.example.com",
454 "deep_link_claim_data": null,
455 "email": "oldformat@example.com",
456 "name": "Old Format User",
457 "deployment_id": "deployment-old",
458 "message_type": "LtiResourceLinkRequest",
459 "roles": ["http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"],
460 "context": null,
461 "resource_link": null
462 }"#;
463
464 let result: Result<ToolJwt, _> = serde_json::from_str(old_format_json);
466 assert!(result.is_err(), "Old format without claim URIs should fail to deserialize");
467 }
468
469 #[tokio::test]
470 async fn test_new_format_with_full_claim_uris() {
471 let new_format_json = r#"{
473 "client_id": "new-format-client",
474 "iss": "https://tool.example.com",
475 "sub": "user-new",
476 "exp": 9999999999,
477 "iat": 9999999000,
478 "names_and_roles_endpoint_url": "https://lms.example.com/nrps",
479 "platform_iss": "https://lms.example.com",
480 "deep_link_claim_data": null,
481 "email": "newformat@example.com",
482 "name": "New Format User",
483 "https://purl.imsglobal.org/spec/lti/claim/deployment_id": "deployment-new",
484 "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiResourceLinkRequest",
485 "https://purl.imsglobal.org/spec/lti/claim/roles": ["http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"],
486 "https://purl.imsglobal.org/spec/lti/claim/context": {
487 "id": "context-new"
488 },
489 "https://purl.imsglobal.org/spec/lti/claim/resource_link": {
490 "id": "link-new"
491 }
492 }"#;
493
494 let tool_jwt: ToolJwt = serde_json::from_str(new_format_json)
495 .expect("New format with full claim URIs should deserialize successfully");
496
497 assert_eq!(tool_jwt.client_id, "new-format-client");
498 assert_eq!(tool_jwt.deployment_id, "deployment-new");
499 assert_eq!(tool_jwt.message_type, "LtiResourceLinkRequest");
500 assert_eq!(tool_jwt.email, Some("newformat@example.com".to_string()));
501 assert_eq!(tool_jwt.name, Some("New Format User".to_string()));
502 assert!(tool_jwt.context.is_some());
503 assert_eq!(tool_jwt.context.unwrap().id, "context-new");
504 assert!(tool_jwt.resource_link.is_some());
505 assert_eq!(tool_jwt.resource_link.unwrap().id, "link-new");
506 }
507
508 #[tokio::test]
509 async fn test_encode_produces_full_claim_uris() {
510 let tool_jwt = ToolJwt {
512 client_id: "uri-test-client".to_string(),
513 iss: "https://tool.example.com".to_string(),
514 sub: "user-uri-test".to_string(),
515 exp: (Utc::now() + Duration::minutes(60)).timestamp(),
516 iat: Utc::now().timestamp(),
517 names_and_roles_endpoint_url: None,
518 platform_iss: "https://lms.example.com".to_string(),
519 deep_link_claim_data: None,
520 email: Some("uritest@example.com".to_string()),
521 name: Some("URI Test User".to_string()),
522 deployment_id: "deployment-uri".to_string(),
523 message_type: "LtiResourceLinkRequest".to_string(),
524 roles: vec!["http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor".to_string()],
525 context: Some(LtiContextClaim {
526 id: "context-uri".to_string(),
527 }),
528 resource_link: Some(LtiResourceLinkClaim {
529 id: "link-uri".to_string(),
530 }),
531 };
532
533 let key_store = MockKeyStore::default();
534 let encoded_jwt = encode_using_store(&tool_jwt, &key_store)
535 .await
536 .expect("Failed to encode ToolJwt");
537
538 let parts: Vec<&str> = encoded_jwt.split('.').collect();
540 assert_eq!(parts.len(), 3, "JWT should have 3 parts");
541
542 let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1])
544 .expect("Failed to decode JWT payload");
545 let payload_json = String::from_utf8(payload_bytes)
546 .expect("Failed to convert payload to string");
547
548 assert!(payload_json.contains("https://purl.imsglobal.org/spec/lti/claim/deployment_id"));
550 assert!(payload_json.contains("https://purl.imsglobal.org/spec/lti/claim/message_type"));
551 assert!(payload_json.contains("https://purl.imsglobal.org/spec/lti/claim/roles"));
552 assert!(payload_json.contains("https://purl.imsglobal.org/spec/lti/claim/context"));
553 assert!(payload_json.contains("https://purl.imsglobal.org/spec/lti/claim/resource_link"));
554 }
555}