1use crate::jwt::insecure_decode;
2use crate::lti_definitions::{LTI_DEEP_LINKING_REQUEST, RESOURCE_LINK_CLAIM};
3use crate::names_and_roles::NamesAndRolesClaim;
4use crate::{errors::SecureError, lti_definitions::NAMES_AND_ROLES_SERVICE_VERSIONS};
5use chrono::{Duration, Utc};
6use serde::de::{self, Unexpected};
7use serde::{Deserialize, Deserializer, Serialize};
8use serde_with::skip_serializing_none;
9use std::collections::HashMap;
10use strum_macros::EnumString;
11
12#[derive(Debug, Deserialize, Serialize, Clone)]
13pub struct IdTokenErrors {
14 errors: Option<Errors>,
15}
16
17#[derive(Debug, Deserialize, Serialize, Clone)]
18pub struct Errors {
19 errors: Option<()>,
20}
21
22#[derive(Debug, PartialEq, EnumString, Deserialize, Serialize, Clone)]
23#[strum(ascii_case_insensitive)]
24pub enum DocumentTargets {
25 #[serde(rename = "iframe")]
26 Iframe,
27 #[serde(rename = "window")]
28 Window,
29 #[serde(rename = "embed")]
30 Embed,
31}
32
33#[derive(Debug, PartialEq, EnumString, Deserialize, Serialize, Clone)]
34#[strum(ascii_case_insensitive)]
35pub enum AcceptTypes {
36 #[serde(rename = "link")]
37 Link,
38 #[serde(rename = "file")]
39 File,
40 #[serde(rename = "html")]
41 Html,
42 #[serde(rename = "ltiResourceLink")]
43 #[serde(
44 alias = "ltiResourceLink",
45 alias = "ltiresourcelink",
46 alias = "LtiResourceLink"
47 )]
48 LtiResourceLink,
49 #[serde(rename = "image")]
50 Image,
51}
52
53#[skip_serializing_none]
55#[derive(Debug, Deserialize, Serialize, Clone)]
56pub struct ResourceLinkClaim {
57 pub id: String,
62 pub description: Option<String>,
64 pub title: Option<String>,
66 pub validation_context: Option<String>,
67 pub errors: Option<IdTokenErrors>,
68}
69
70#[skip_serializing_none]
72#[derive(Debug, Deserialize, Serialize, Clone)]
73pub struct LaunchPresentationClaim {
74 pub document_target: Option<DocumentTargets>,
77 pub return_url: Option<String>,
81 pub locale: String,
83 pub height: Option<i32>,
85 pub width: Option<i32>,
87 pub validation_context: Option<String>,
88 pub errors: Option<IdTokenErrors>,
89}
90
91#[skip_serializing_none]
92#[derive(Debug, Deserialize, Serialize, Clone)]
93pub struct DeepLinkingClaim {
94 pub deep_link_return_url: String,
95 pub accept_types: Vec<AcceptTypes>,
96 pub accept_presentation_document_targets: Vec<DocumentTargets>,
97 pub accept_media_types: Option<String>,
98 pub accept_multiple: Option<bool>,
99 pub accept_lineitem: Option<bool>,
100 pub auto_create: Option<bool>,
101 pub title: Option<String>,
102 pub text: Option<String>,
103 pub data: Option<String>,
104}
105
106#[derive(Debug, PartialEq, EnumString, Deserialize, Serialize, Clone)]
107pub enum AGSScopes {
108 #[serde(rename = "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem")]
109 LineItem,
110 #[serde(rename = "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly")]
111 ResultReadOnly,
112 #[serde(rename = "https://purl.imsglobal.org/spec/lti-ags/scope/score")]
113 Score,
114 #[serde(rename = "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly")]
115 LineItemReadOnly,
116}
117
118#[skip_serializing_none]
119#[derive(Debug, Deserialize, Serialize, Clone)]
120pub struct AGSClaim {
121 pub scope: Vec<AGSScopes>,
122 pub lineitems: String,
123 pub lineitem: Option<String>,
124 pub validation_context: Option<String>,
125 pub errors: Option<IdTokenErrors>,
126}
127
128#[skip_serializing_none]
129#[derive(Debug, Deserialize, Serialize, Clone)]
130pub struct LISClaim {
131 pub person_sourcedid: String,
132 pub course_offering_sourcedid: Option<String>,
133 pub course_section_sourcedid: Option<String>,
134 pub validation_context: Option<String>,
135 pub errors: Option<IdTokenErrors>,
136}
137
138#[skip_serializing_none]
139#[derive(Debug, Deserialize, Serialize, Clone)]
140pub struct ContextClaim {
141 pub id: String,
142 pub label: Option<String>,
143 pub title: Option<String>,
144 pub r#type: Option<Vec<String>>,
145 pub validation_context: Option<String>,
146 pub errors: Option<IdTokenErrors>,
147}
148
149#[skip_serializing_none]
150#[derive(Debug, Deserialize, Serialize, Clone)]
151pub struct ToolPlatformClaim {
152 pub guid: String,
153 pub contact_email: Option<String>,
154 pub description: Option<String>,
155 pub name: Option<String>,
156 pub url: Option<String>,
157 pub product_family_code: Option<String>,
158 pub version: Option<String>,
159 pub validation_context: Option<String>,
160 pub errors: Option<IdTokenErrors>,
161}
162
163fn aud_deserializer<'de, D>(deserializer: D) -> Result<String, D::Error>
166where
167 D: Deserializer<'de>,
168{
169 let aud: serde_json::Value = Deserialize::deserialize(deserializer)?;
170 match aud {
171 serde_json::Value::String(s) => {
172 dbg!(&s);
173 Ok(s)
174 }
175 serde_json::Value::Array(arr) => {
176 if let Some(serde_json::Value::String(first_aud)) = arr.first() {
177 Ok(first_aud.clone())
178 } else {
179 Err(de::Error::invalid_type(
180 Unexpected::Seq,
181 &"a string or an array of strings",
182 ))
183 }
184 }
185 _ => Err(de::Error::invalid_type(
186 Unexpected::Other("non-string"),
187 &"a string or an array of strings",
188 )),
189 }
190}
191
192fn auds_deserializer<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
195where
196 D: Deserializer<'de>,
197{
198 let aud: serde_json::Value = Deserialize::deserialize(deserializer)?;
199 match aud {
200 serde_json::Value::String(_) => Ok(None),
202 serde_json::Value::Array(arr) => {
204 let mut auds = Vec::new();
205 for elem in arr {
206 if let serde_json::Value::String(s) = elem {
207 auds.push(s);
208 } else {
209 return Err(de::Error::invalid_type(
210 Unexpected::Other("non-string element in array"),
211 &"an array of strings",
212 ));
213 }
214 }
215 Ok(Some(auds))
216 }
217 _ => Err(de::Error::invalid_type(
219 Unexpected::Other("non-string"),
220 &"a string or an array of strings",
221 )),
222 }
223}
224
225#[skip_serializing_none]
226#[derive(Debug, Deserialize, Serialize, Clone)]
227pub struct IdToken {
228 #[serde(default, deserialize_with = "aud_deserializer")]
229 pub aud: String,
230 #[serde(default, deserialize_with = "auds_deserializer")]
231 pub auds: Option<Vec<String>>,
232 #[serde(default)]
233 pub azp: Option<String>,
234 pub exp: i64,
235 pub iat: i64,
236 pub iss: String,
237 pub nonce: String,
238 pub sub: String,
239
240 #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/message_type")]
241 pub message_type: String,
242 #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/version")]
243 pub lti_version: String,
244 #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/resource_link")]
245 pub resource_link: Option<ResourceLinkClaim>,
246 #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/deployment_id")]
247 pub deployment_id: String,
248 #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/target_link_uri")]
249 pub target_link_uri: String,
250 #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/roles")]
251 pub roles: Vec<String>,
252 #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/role_scope_mentor")]
253 pub role_scope_mentor: Option<Vec<String>>,
254 #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/context")]
255 pub context: Option<ContextClaim>,
256 #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/tool_platform")]
257 pub tool_platform: Option<ToolPlatformClaim>,
258 #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/launch_presentation")]
259 pub launch_presentation: Option<LaunchPresentationClaim>,
260 #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/lis")]
261 pub lis: Option<LISClaim>,
262 #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/custom")]
266 pub custom: Option<HashMap<String, String>>,
267 #[serde(rename = "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings")]
268 pub deep_linking: Option<DeepLinkingClaim>,
269 #[serde(rename = "https://purl.imsglobal.org/spec/lti-dl/claim/data ")]
270 pub data: Option<String>,
271 #[serde(rename = "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice")]
272 pub names_and_roles: Option<NamesAndRolesClaim>,
273 #[serde(rename = "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint")]
274 pub ags: Option<AGSClaim>,
275
276 pub lti11_legacy_user_id: Option<String>,
277 pub picture: Option<String>,
278 pub email: Option<String>,
279 pub name: Option<String>,
280 pub given_name: Option<String>,
281 pub family_name: Option<String>,
282 pub middle_name: Option<String>,
283 pub locale: Option<String>,
284
285 pub errors: Option<IdTokenErrors>,
286}
287
288impl IdToken {
289 pub fn extract_iss(token: &str) -> Result<String, SecureError> {
291 let id_token = insecure_decode::<IdToken>(token)?.claims;
292 Ok(id_token.iss)
293 }
294
295 pub fn client_id(&self) -> String {
297 match &self.auds {
298 Some(auds) if auds.len() > 1 => {
299 self.azp.clone().unwrap_or_else(|| auds[0].clone())
301 }
302 Some(auds) => {
303 auds[0].clone()
305 }
306 None => {
307 self.aud.clone()
309 }
310 }
311 }
312
313 pub fn lms_host(&self) -> Option<String> {
315 if self.is_deep_link_launch() {
316 self
317 .deep_linking
318 .as_ref()
319 .map(|dl| dl.deep_link_return_url.clone())
320 } else {
321 self
322 .launch_presentation
323 .as_ref()
324 .and_then(|lp| lp.return_url.clone())
325 }
326 }
327
328 pub fn lms_url(&self) -> Option<String> {
330 self.lms_host().map(|host| format!("https://{}", host))
331 }
332
333 pub fn is_deep_link_launch(&self) -> bool {
335 self.message_type == LTI_DEEP_LINKING_REQUEST
336 }
337
338 pub fn is_names_and_roles_launch(&self) -> bool {
340 match &self.names_and_roles {
341 None => false,
342 Some(nar) => {
343 if !nar
344 .service_versions
345 .iter()
346 .any(|v| v == NAMES_AND_ROLES_SERVICE_VERSIONS[0])
347 {
348 return false;
349 }
350 true
351 }
352 }
353 }
354
355 pub fn names_and_roles_endpoint(&self) -> Option<String> {
357 self
358 .names_and_roles
359 .as_ref()
360 .map(|nar| nar.context_memberships_url.clone())
361 }
362
363 pub fn is_assignment_and_grades_launch(&self) -> bool {
365 self.ags.is_some()
366 }
367
368 pub fn validate(&self, requested_target_link_uri: &str) -> Vec<String> {
370 let mut errors = Vec::new();
371
372 if self.target_link_uri != requested_target_link_uri {
373 errors.push(format!(
374 "LTI token target link uri '{}' doesn't match url '{}'",
375 self.target_link_uri, requested_target_link_uri
376 ));
377 }
378
379 if let Some(resource_link) = &self.resource_link {
380 if resource_link.id.is_empty() {
381 errors.push(format!(
382 "LTI token is missing required field id from the claim {}",
383 RESOURCE_LINK_CLAIM
384 ));
385 }
386 }
387
388 if let Some(auds) = &self.auds {
389 if auds.len() > 1 {
390 if self.azp.is_none() {
391 errors.push("LTI token is missing required field azp".to_string());
392 } else if !auds.contains(&self.azp.clone().unwrap_or_default()) {
393 errors.push("azp is not one of the aud's".to_owned());
394 }
395 }
396 }
397
398 if !self.lti_version.starts_with("1.3") {
399 errors.push("Invalid LTI version".to_owned());
400 }
401
402 errors
403 }
404
405 pub fn to_client_id_token(&self) -> IdToken {
409 let mut id_token = self.clone();
410 id_token.launch_presentation = None;
411
412 id_token
419 }
420}
421
422impl Default for IdToken {
423 fn default() -> Self {
424 Self {
425 aud: "".to_string(),
426 auds: None,
427 azp: None,
428 exp: (Utc::now() + Duration::minutes(15)).timestamp(),
429 iat: Utc::now().timestamp(),
430 iss: "".to_string(),
431 nonce: "".to_string(),
432 sub: "".to_string(),
433 message_type: "".to_string(),
434 lti_version: "".to_string(),
435 resource_link: Some(ResourceLinkClaim {
436 id: "".to_string(),
437 description: None,
438 title: None,
439 validation_context: None,
440 errors: None,
441 }),
442 deployment_id: "".to_string(),
443 target_link_uri: "".to_string(),
444 roles: vec![],
445 role_scope_mentor: Some(vec![]),
446 context: None,
447 tool_platform: None,
448 launch_presentation: None,
449 lis: None,
450 custom: None,
452 deep_linking: None,
453 data: None,
454 names_and_roles: None,
455 ags: None,
456 lti11_legacy_user_id: None,
457 picture: None,
458 email: None,
459 name: None,
460 given_name: None,
461 family_name: None,
462 middle_name: None,
463 locale: None,
464 errors: None,
465 }
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472 use crate::jwks::{decode, encode, generate_jwk, Jwks};
473 use jsonwebtoken::jwk::JwkSet;
474 use openssl::rsa::Rsa;
475
476 #[test]
477 fn test_id_token_incorrect_target_link_uri() {
478 let id_token = IdToken {
479 target_link_uri: "notexample.com".to_string(),
480 resource_link: Some(ResourceLinkClaim {
481 id: "123".to_string(),
482 description: None,
483 title: None,
484 validation_context: None,
485 errors: None,
486 }),
487 auds: Some(vec!["example.com".to_string()]),
488 azp: Some("".to_string()),
489 aud: "example.com".to_string(),
490 lti_version: "1.3".to_string(),
491 ..Default::default()
492 };
493 let errors = id_token.validate("example.com");
494 assert_eq!(errors.len(), 1);
495 }
496
497 #[test]
498 fn test_lms_host() {
499 let id_token = IdToken {
500 message_type: LTI_DEEP_LINKING_REQUEST.to_string(),
501 deep_linking: Some(DeepLinkingClaim {
502 deep_link_return_url: "example.com".to_string(),
503 accept_types: vec![AcceptTypes::Link],
504 accept_presentation_document_targets: vec![DocumentTargets::Iframe],
505 accept_media_types: None,
506 accept_multiple: None,
507 accept_lineitem: None,
508 auto_create: None,
509 title: None,
510 text: None,
511 data: None,
512 }),
513 launch_presentation: None,
514 ..Default::default()
515 };
516 assert_eq!(id_token.lms_host(), Some("example.com".to_string()));
517 }
518
519 #[test]
520 fn test_lms_url() {
521 let id_token = IdToken {
522 message_type: LTI_DEEP_LINKING_REQUEST.to_string(),
523 deep_linking: Some(DeepLinkingClaim {
524 deep_link_return_url: "example.com".to_string(),
525 accept_types: vec![AcceptTypes::Link],
526 accept_presentation_document_targets: vec![DocumentTargets::Iframe],
527 accept_media_types: None,
528 accept_multiple: None,
529 accept_lineitem: None,
530 auto_create: None,
531 title: None,
532 text: None,
533 data: None,
534 }),
535 launch_presentation: None,
536 ..Default::default()
537 };
538 assert_eq!(id_token.lms_url(), Some("https://example.com".to_string()));
539 }
540
541 #[test]
542 fn test_is_deep_link_launch() {
543 let id_token = IdToken {
544 message_type: LTI_DEEP_LINKING_REQUEST.to_string(),
545 ..Default::default()
546 };
547 assert!(id_token.is_deep_link_launch());
548 }
549
550 #[test]
551 fn test_is_names_and_roles_launch() {
552 let id_token = IdToken {
553 names_and_roles: Some(NamesAndRolesClaim {
554 context_memberships_url: "example.com".to_string(),
555 service_versions: vec![NAMES_AND_ROLES_SERVICE_VERSIONS[0].to_string()],
556 validation_context: None,
557 errors: None,
558 }),
559 ..Default::default()
560 };
561 assert!(id_token.is_names_and_roles_launch());
562 }
563
564 #[test]
565 fn test_is_assignment_and_grades_launch() {
566 let id_token = IdToken {
567 ags: Some(AGSClaim {
568 scope: vec![AGSScopes::LineItem],
569 lineitems: "example.com".to_string(),
570 lineitem: None,
571 validation_context: None,
572 errors: None,
573 }),
574 ..Default::default()
575 };
576 assert!(id_token.is_assignment_and_grades_launch());
577 }
578
579 #[test]
580 fn test_validate() {
581 let id_token = IdToken {
582 target_link_uri: "example.com".to_string(),
583 resource_link: Some(ResourceLinkClaim {
584 id: "123".to_string(),
585 description: None,
586 title: None,
587 validation_context: None,
588 errors: None,
589 }),
590 auds: Some(vec!["example.com".to_string()]),
591 azp: Some("".to_string()),
592 aud: "example.com".to_string(),
593 lti_version: "1.3".to_string(),
594 ..Default::default()
595 };
596 let errors = id_token.validate("example.com");
597 assert_eq!(errors.len(), 0);
598 }
599
600 #[test]
601 fn test_extract_iss() {
602 let iss = "https://lms.example.com";
603 let aud = "1234";
604 let user_id = "12";
605 let rsa_key_pair = Rsa::generate(2048).expect("Failed to generate RSA key");
606 let kid = "asdf_kid";
607 let jwk = generate_jwk(kid, &rsa_key_pair).expect("Failed to generate JWK");
608
609 let expiration = Utc::now() + Duration::minutes(15);
611
612 let id_token = IdToken {
614 iss: iss.to_string(),
615 sub: user_id.to_string(),
616 aud: aud.to_string(),
617 exp: expiration.timestamp(),
618 message_type: LTI_DEEP_LINKING_REQUEST.to_string(),
619 deep_linking: Some(DeepLinkingClaim {
620 deep_link_return_url: "example.com".to_string(),
621 accept_types: vec![AcceptTypes::Link],
622 accept_presentation_document_targets: vec![DocumentTargets::Iframe],
623 accept_media_types: None,
624 accept_multiple: None,
625 accept_lineitem: None,
626 auto_create: None,
627 title: None,
628 text: None,
629 data: None,
630 }),
631 launch_presentation: None,
632 ..Default::default()
633 };
634
635 let token = encode(&id_token, &jwk.kid, rsa_key_pair).expect("Failed to encode token");
637
638 let extracted_iss = IdToken::extract_iss(&token).unwrap();
640 assert_eq!(extracted_iss, iss);
641 }
642
643 #[test]
644 fn test_decode() {
645 let iss = "https://lms.example.com";
646 let aud = "1234";
647 let user_id = "12";
648 let rsa_key_pair = Rsa::generate(2048).expect("Failed to generate RSA key");
649 let kid = "asdf_kid";
650 let jwk = generate_jwk(kid, &rsa_key_pair).expect("Failed to generate JWK");
651 let jwks = Jwks {
652 keys: vec![jwk.clone()],
653 };
654 let jwks_json = serde_json::to_string(&jwks).expect("Failed to generate JSON for JWKS");
655
656 let expiration = Utc::now() + Duration::minutes(15);
658
659 let id_token = IdToken {
661 iss: iss.to_string(),
662 sub: user_id.to_string(),
663 aud: aud.to_string(),
664 exp: expiration.timestamp(),
665 message_type: LTI_DEEP_LINKING_REQUEST.to_string(),
666 deep_linking: Some(DeepLinkingClaim {
667 deep_link_return_url: "example.com".to_string(),
668 accept_types: vec![AcceptTypes::Link],
669 accept_presentation_document_targets: vec![DocumentTargets::Iframe],
670 accept_media_types: None,
671 accept_multiple: None,
672 accept_lineitem: None,
673 auto_create: None,
674 title: None,
675 text: None,
676 data: None,
677 }),
678 launch_presentation: None,
679 ..Default::default()
680 };
681
682 let token = encode(&id_token, &jwk.kid, rsa_key_pair).expect("Failed to encode token");
684
685 let jwk_set: JwkSet = serde_json::from_str(&jwks_json).expect("Failed to parse JWKS");
687 let extracted_id_token = decode(&token, &jwk_set).expect("Failed to decode token");
688 assert_eq!(extracted_id_token.aud, aud);
689 }
690}