atomic_lti/
platforms.rs

1use crate::{
2  errors::PlatformError,
3  stores::platform_store::{PlatformData, PlatformStore},
4};
5use async_trait::async_trait;
6use cached::proc_macro::cached;
7use jsonwebtoken::jwk::JwkSet;
8use phf::phf_map;
9use reqwest::{header, Client};
10use serde::{Deserialize, Serialize};
11use tokio::time::Duration;
12
13pub const USER_AGENT: &str =
14  "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible;) LTI JWK Requester";
15
16pub const CANVAS_PUBLIC_JWKS_URL: &str = "https://sso.canvaslms.com/api/lti/security/jwks";
17pub const CANVAS_OIDC_URL: &str = "https://sso.canvaslms.com/api/lti/authorize_redirect";
18pub const CANVAS_AUTH_TOKEN_URL: &str = "https://sso.canvaslms.com/login/oauth2/token";
19
20pub const CANVAS_BETA_PUBLIC_JWKS_URL: &str =
21  "https://sso.beta.canvaslms.com/api/lti/security/jwks";
22pub const CANVAS_BETA_AUTH_TOKEN_URL: &str = "https://sso.beta.canvaslms.com/login/oauth2/token";
23pub const CANVAS_BETA_OIDC_URL: &str = "https://sso.beta.canvaslms.com/api/lti/authorize_redirect";
24
25pub const CANVAS_SUBMISSION_TYPE: &str = "https://canvas.instructure.com/lti/submission_type";
26
27#[derive(Debug, Deserialize, Serialize)]
28pub struct Platform<'a> {
29  pub iss: &'a str,
30  pub jwks_url: &'a str,
31  pub token_url: &'a str,
32  pub oidc_url: &'a str,
33}
34
35static PLATFORMS: phf::Map<&'static str, Platform> = phf_map! {
36  "https://canvas.instructure.com" =>
37  Platform {
38    iss: "https://canvas.instructure.com",
39    jwks_url: CANVAS_PUBLIC_JWKS_URL,
40    token_url: CANVAS_AUTH_TOKEN_URL,
41    oidc_url: CANVAS_OIDC_URL,
42  },
43  "https://canvas.beta.instructure.com" =>
44  Platform {
45    iss: "https://canvas.beta.instructure.com",
46    jwks_url: CANVAS_BETA_PUBLIC_JWKS_URL,
47    token_url: CANVAS_BETA_AUTH_TOKEN_URL,
48    oidc_url: CANVAS_BETA_OIDC_URL,
49  },
50  "https://schoology.schoology.com" =>
51  Platform {
52    iss: "https://schoology.schoology.com",
53    jwks_url: "https://lti-service.svc.schoology.com/lti-service/.well-known/jwks",
54    token_url: "https://lti-service.svc.schoology.com/lti-service/access-token",
55    oidc_url: "https://lti-service.svc.schoology.com/lti-service/authorize-redirect",
56  },
57  "https://ltiadvantagevalidator.imsglobal.org" =>
58  Platform {
59    iss: "https://ltiadvantagevalidator.imsglobal.org",
60    jwks_url: "https://oauth2server.imsglobal.org/jwks",
61    token_url: "https://ltiadvantagevalidator.imsglobal.org/ltitool/authcodejwt.html",
62    oidc_url: "https://ltiadvantagevalidator.imsglobal.org/ltitool/oidcauthurl.html",
63  },
64  "https://build.1edtech.org" =>
65  Platform {
66    iss: "https://build.1edtech.org",
67    jwks_url: "https://build.1edtech.org/jwks",
68    token_url: "https://build.1edtech.org/auth",
69    oidc_url: "https://build.1edtech.org/oidc",
70  },
71  "https://lms.example.com" =>
72  Platform {
73    iss: "https://lms.example.com",
74    jwks_url: "https://lms.example.com/jwks",
75    token_url: "https://lms.example.com/auth",
76    oidc_url: "https://lms.example.com/oidc",
77  },
78};
79
80pub struct StaticPlatformStore<'a> {
81  pub iss: &'a str,
82}
83
84#[async_trait]
85impl PlatformStore for StaticPlatformStore<'_> {
86  async fn get_jwk_server_url(&self) -> Result<String, PlatformError> {
87    let platform = self.get_platform()?;
88    Ok(platform.jwks_url.to_string())
89  }
90
91  async fn get_oidc_url(&self) -> Result<String, PlatformError> {
92    let platform = self.get_platform()?;
93    Ok(platform.oidc_url.to_string())
94  }
95
96  async fn get_token_url(&self) -> Result<String, PlatformError> {
97    let platform = self.get_platform()?;
98    Ok(platform.token_url.to_string())
99  }
100
101  async fn create(&self, _platform: PlatformData) -> Result<PlatformData, PlatformError> {
102    Err(PlatformError::UnsupportedOperation(
103      "StaticPlatformStore does not support create operations".to_string(),
104    ))
105  }
106
107  async fn find_by_iss(&self, issuer: &str) -> Result<Option<PlatformData>, PlatformError> {
108    match PLATFORMS.get(issuer) {
109      Some(platform) => Ok(Some(PlatformData {
110        issuer: platform.iss.to_string(),
111        name: None,
112        jwks_url: platform.jwks_url.to_string(),
113        token_url: platform.token_url.to_string(),
114        oidc_url: platform.oidc_url.to_string(),
115      })),
116      None => Ok(None),
117    }
118  }
119
120  async fn update(
121    &self,
122    _issuer: &str,
123    _platform: PlatformData,
124  ) -> Result<PlatformData, PlatformError> {
125    Err(PlatformError::UnsupportedOperation(
126      "StaticPlatformStore does not support update operations".to_string(),
127    ))
128  }
129
130  async fn delete(&self, _issuer: &str) -> Result<(), PlatformError> {
131    Err(PlatformError::UnsupportedOperation(
132      "StaticPlatformStore does not support delete operations".to_string(),
133    ))
134  }
135
136  async fn list(&self) -> Result<Vec<PlatformData>, PlatformError> {
137    let platforms: Vec<PlatformData> = PLATFORMS
138      .values()
139      .map(|p| PlatformData {
140        issuer: p.iss.to_string(),
141        name: None,
142        jwks_url: p.jwks_url.to_string(),
143        token_url: p.token_url.to_string(),
144        oidc_url: p.oidc_url.to_string(),
145      })
146      .collect();
147    Ok(platforms)
148  }
149}
150
151impl StaticPlatformStore<'_> {
152  fn get_platform(&self) -> Result<&Platform, PlatformError> {
153    let platform = PLATFORMS
154      .get(self.iss)
155      .ok_or(PlatformError::InvalidIss(self.iss.to_string()))?;
156    Ok(platform)
157  }
158}
159
160#[cached(
161  time = 3600, // 1 hour
162  result = true, // Only "Ok" results are cached
163  sync_writes = "default", // When called concurrently, duplicate argument-calls will be synchronized so as to only run once
164)]
165pub async fn get_jwk_set(jwk_server_url: String) -> Result<JwkSet, PlatformError> {
166  let client = Client::new();
167  let resp = client
168    .get(jwk_server_url)
169    .header(header::USER_AGENT, USER_AGENT)
170    .send()
171    .await
172    .map_err(|e| PlatformError::JWKSRequestFailed(e.to_string()))?;
173
174  let jwks_resp = resp
175    .text()
176    .await
177    .map_err(|e| PlatformError::JWKSRequestFailed(e.to_string()))?;
178
179  let jwks: JwkSet = serde_json::from_str(&jwks_resp)
180    .map_err(|e| PlatformError::JWKSRequestFailed(e.to_string()))?;
181
182  Ok(jwks)
183}
184
185#[cfg(test)]
186mod tests {
187  use super::*;
188
189  const TEST_STORE: StaticPlatformStore = StaticPlatformStore {
190    iss: "https://lms.example.com",
191  };
192
193  const INVALID_TEST_STORE: StaticPlatformStore = StaticPlatformStore {
194    iss: "https://invalid.com",
195  };
196
197  #[test]
198  fn test_get_platform() {
199    let result = TEST_STORE.get_platform();
200    assert!(result.is_ok());
201
202    let platform = result.unwrap();
203    assert_eq!(platform.iss, "https://lms.example.com");
204    assert_eq!(platform.jwks_url, "https://lms.example.com/jwks");
205    assert_eq!(platform.oidc_url, "https://lms.example.com/oidc");
206  }
207
208  #[tokio::test]
209  async fn test_get_jwk_server_url() {
210    let result = TEST_STORE.get_jwk_server_url().await;
211    assert!(result.is_ok());
212
213    let jwk_server_url = result.unwrap();
214    assert_eq!(jwk_server_url, "https://lms.example.com/jwks");
215  }
216
217  #[tokio::test]
218  async fn test_get_oidc_url() {
219    let result = TEST_STORE.get_oidc_url().await;
220    assert!(result.is_ok());
221
222    let platform_oidc_url = result.unwrap();
223    assert_eq!(platform_oidc_url, "https://lms.example.com/oidc");
224  }
225
226  #[test]
227  fn test_get_platform_invalid_iss() {
228    let result = INVALID_TEST_STORE.get_platform();
229    assert!(result.is_err());
230
231    let error = result.unwrap_err();
232    assert_eq!(
233      error,
234      PlatformError::InvalidIss("https://invalid.com".to_string())
235    );
236  }
237
238  #[tokio::test]
239  async fn test_get_jwk_server_url_invalid_iss() {
240    let result = INVALID_TEST_STORE.get_jwk_server_url().await;
241    assert!(result.is_err());
242
243    let error = result.unwrap_err();
244    assert_eq!(
245      error,
246      PlatformError::InvalidIss("https://invalid.com".to_string())
247    );
248  }
249
250  #[tokio::test]
251  async fn test_get_oidc_url_invalid_iss() {
252    let result = INVALID_TEST_STORE.get_oidc_url().await;
253    assert!(result.is_err());
254
255    let error = result.unwrap_err();
256    assert_eq!(
257      error,
258      PlatformError::InvalidIss("https://invalid.com".to_string())
259    );
260  }
261}