Skip to main content

atomic_lti_tool/
tool_jwt.rs

1use serde::{Deserialize, Serialize};
2
3/// Nested struct for LTI context claim
4///
5/// This represents the context (e.g., course) in which the LTI launch is occurring.
6/// See: https://www.imsglobal.org/spec/lti/v1p3#context-claim
7#[derive(Debug, Deserialize, Serialize, Clone)]
8pub struct LtiContextClaim {
9  /// Opaque identifier for the context (e.g., course ID)
10  pub id: String,
11}
12
13/// Nested struct for LTI resource link claim
14///
15/// This represents a specific placement of a tool within a context.
16/// See: https://www.imsglobal.org/spec/lti/v1p3#resource-link-claim
17#[derive(Debug, Deserialize, Serialize, Clone)]
18pub struct LtiResourceLinkClaim {
19  /// Opaque identifier for the resource link placement
20  pub id: String,
21}
22
23/// JWT structure for Tool-issued tokens
24///
25/// This structure represents the JWT token that the LTI Tool issues to clients
26/// after a successful LTI launch. It contains essential claims from the original
27/// LTI ID Token plus metadata needed by the client application.
28///
29/// The structure uses full LTI claim URIs with serde rename attributes to ensure
30/// proper serialization/deserialization while maintaining clean field names in code.
31///
32/// # Example
33///
34/// ```rust
35/// use atomic_lti_tool::tool_jwt::{ToolJwt, LtiContextClaim, LtiResourceLinkClaim};
36/// use chrono::{Duration, Utc};
37///
38/// let tool_jwt = ToolJwt {
39///     client_id: "my-client-123".to_string(),
40///     iss: "https://mytool.example.com".to_string(),
41///     sub: "user-456".to_string(),
42///     exp: (Utc::now() + Duration::minutes(60)).timestamp(),
43///     iat: Utc::now().timestamp(),
44///     names_and_roles_endpoint_url: Some("https://lms.example.com/nrps".to_string()),
45///     platform_iss: "https://lms.example.com".to_string(),
46///     deep_link_claim_data: None,
47///     email: Some("user@example.com".to_string()),
48///     name: Some("Test User".to_string()),
49///     deployment_id: "deployment-789".to_string(),
50///     message_type: "LtiResourceLinkRequest".to_string(),
51///     roles: vec!["http://purl.imsglobal.org/vocab/lis/v2/membership#Learner".to_string()],
52///     context: Some(LtiContextClaim {
53///         id: "course-123".to_string(),
54///     }),
55///     resource_link: Some(LtiResourceLinkClaim {
56///         id: "link-456".to_string(),
57///     }),
58/// };
59/// ```
60#[derive(Debug, Deserialize, Serialize, Clone)]
61pub struct ToolJwt {
62  /// Client ID from the LTI platform (identifies the tool registration)
63  pub client_id: String,
64
65  /// Issuer - the URL of this tool
66  pub iss: String,
67
68  /// Subject - the user ID from the LMS
69  pub sub: String,
70
71  /// Expiration time (Unix timestamp)
72  pub exp: i64,
73
74  /// Issued at time (Unix timestamp)
75  pub iat: i64,
76
77  /// Optional endpoint URL for Names and Roles Provisioning Service
78  pub names_and_roles_endpoint_url: Option<String>,
79
80  /// Issuer of the original platform (LMS URL)
81  pub platform_iss: String,
82
83  /// Optional data from deep linking claim
84  pub deep_link_claim_data: Option<String>,
85
86  /// User's email address from the LTI platform
87  pub email: Option<String>,
88
89  /// User's full name from the LTI platform
90  pub name: Option<String>,
91
92  // LTI claims with full claim URIs for client compatibility
93
94  /// LTI Deployment ID - identifies a specific deployment of the tool
95  ///
96  /// See: https://www.imsglobal.org/spec/lti/v1p3#deployment-id-claim
97  #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/deployment_id")]
98  pub deployment_id: String,
99
100  /// LTI Message Type (e.g., "LtiResourceLinkRequest", "LtiDeepLinkingRequest")
101  ///
102  /// See: https://www.imsglobal.org/spec/lti/v1p3#message-type-claim
103  #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/message_type")]
104  pub message_type: String,
105
106  /// User's roles within the context (e.g., Learner, Instructor)
107  ///
108  /// See: https://www.imsglobal.org/spec/lti/v1p3#roles-claim
109  #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/roles")]
110  pub roles: Vec<String>,
111
112  /// Context information (e.g., course) - optional as not all launches have context
113  ///
114  /// See: https://www.imsglobal.org/spec/lti/v1p3#context-claim
115  #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/context")]
116  pub context: Option<LtiContextClaim>,
117
118  /// Resource link information - identifies the specific placement of the tool
119  ///
120  /// See: https://www.imsglobal.org/spec/lti/v1p3#resource-link-claim
121  #[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    // Serialize to JSON
158    let json = serde_json::to_string(&tool_jwt).expect("Failed to serialize ToolJwt");
159
160    // Verify that full claim URIs are present in JSON
161    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    // Test with minimal fields (optional fields set to None)
207    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    // Verify they serialize/deserialize correctly
246    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    // Serialize
285    let json = serde_json::to_string(&original).expect("Failed to serialize");
286
287    // Deserialize
288    let deserialized: ToolJwt = serde_json::from_str(&json).expect("Failed to deserialize");
289
290    // Verify all fields match
291    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    // Create a ToolJwt instance
311    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    // Create a mock key store
334    let key_store = MockKeyStore::default();
335
336    // Encode the ToolJwt
337    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    // Decode the JWT
344    let decoded = decode_using_store::<ToolJwt>(&encoded_jwt, &key_store)
345      .await
346      .expect("Failed to decode ToolJwt");
347
348    // Verify all fields match
349    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    // Verify nested claims
360    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    // Test with only required fields (optional fields as None)
369    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    // Encode and decode
390    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    // Verify required fields
399    assert_eq!(decoded.claims.client_id, "minimal-client");
400    assert_eq!(decoded.claims.deployment_id, "deployment-minimal");
401
402    // Verify optional fields are None
403    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    // Encode with first key store
430    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    // Try to decode with different key store (should fail)
436    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    // This test verifies that JWTs without the full claim URIs can still be decoded
445    // This ensures backward compatibility with older implementations
446    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    // This should fail because the old format doesn't have the full claim URIs
465    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    // This test verifies that the new format with full claim URIs works correctly
472    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    // This test verifies that encoding always produces the full claim URIs
511    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    // Decode the JWT string to inspect the raw JSON
539    let parts: Vec<&str> = encoded_jwt.split('.').collect();
540    assert_eq!(parts.len(), 3, "JWT should have 3 parts");
541
542    // Decode the payload (second part)
543    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    // Verify that the full claim URIs are present in the JSON
549    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}