feat: Add LTI launch, lesson preview, course progress, bookmarks, and asset management features.

This commit is contained in:
2026-02-23 15:43:45 -03:00
parent f365e585a2
commit 7f7ea3d70c
45 changed files with 5250 additions and 697 deletions
+255
View File
@@ -0,0 +1,255 @@
use axum::{
extract::{Query, State},
http::StatusCode,
response::{Redirect},
Form,
};
use jsonwebtoken::{decode, decode_header, jwk::JwkSet, DecodingKey, Validation};
use serde::{Deserialize};
use sqlx::{PgPool};
use uuid::Uuid;
use common::models::{LtiLaunchClaims, LtiRegistration, LtiResourceLink, User};
use common::auth::Claims;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
#[derive(Deserialize)]
pub struct LtiLoginParams {
pub iss: String,
pub login_hint: String,
pub target_link_uri: String,
pub lti_message_hint: Option<String>,
pub client_id: Option<String>,
pub lti_deployment_id: Option<String>,
}
pub async fn lti_login_initiation(
State(pool): State<PgPool>,
Query(params): Query<LtiLoginParams>,
) -> Result<Redirect, (StatusCode, String)> {
// 1. Find registration
let registration = sqlx::query_as::<_, LtiRegistration>(
"SELECT * FROM lti_registrations WHERE issuer = $1 AND ($2::text IS NULL OR client_id = $2)"
)
.bind(&params.iss)
.bind(&params.client_id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::BAD_REQUEST, "LTI Registration not found".to_string()))?;
// 2. Generate state and nonce
let state = Uuid::new_v4().to_string();
let nonce = Uuid::new_v4().to_string();
// 3. Store nonce
sqlx::query("INSERT INTO lti_nonces (nonce) VALUES ($1)")
.bind(&nonce)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 4. Construct redirect URL
let mut url = format!(
"{}?scope=openid&response_type=id_token&client_id={}&redirect_uri={}&login_hint={}&state={}&nonce={}&response_mode=form_post",
registration.auth_login_url,
registration.client_id,
urlencoding::encode(&params.target_link_uri),
urlencoding::encode(&params.login_hint),
state,
nonce
);
if let Some(hint) = params.lti_message_hint {
url.push_str(&format!("&lti_message_hint={}", urlencoding::encode(&hint)));
}
Ok(Redirect::to(&url))
}
#[derive(Deserialize)]
pub struct LtiLaunchParams {
pub id_token: String,
pub state: String,
}
pub async fn validate_lti_jwt(
id_token: &str,
jwks_url: &str,
client_id: &str,
) -> Result<LtiLaunchClaims, String> {
let header = decode_header(id_token).map_err(|e| e.to_string())?;
let kid = header.kid.ok_or("Missing kid in JWT header")?;
// Fetch JWKS
let jwks: JwkSet = reqwest::get(jwks_url)
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
let jwk = jwks.find(&kid).ok_or("JWK not found for kid")?;
let decoding_key = DecodingKey::from_jwk(jwk).map_err(|e| e.to_string())?;
let mut validation = Validation::new(jsonwebtoken::Algorithm::RS256);
validation.set_audience(&[client_id]);
let token_data = decode::<LtiLaunchClaims>(id_token, &decoding_key, &validation)
.map_err(|e| e.to_string())?;
Ok(token_data.claims)
}
pub async fn lti_launch(
State(pool): State<PgPool>,
Form(payload): Form<LtiLaunchParams>,
) -> Result<Redirect, (StatusCode, String)> {
// 1. Decode claims manually to find registration (since we don't have the key yet)
let parts: Vec<&str> = payload.id_token.split('.').collect();
if parts.len() != 3 {
return Err((StatusCode::BAD_REQUEST, "Invalid JWT format".to_string()));
}
let decoded_claims = URL_SAFE_NO_PAD.decode(parts[1])
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid base64 in JWT payload: {}", e)))?;
let claims: serde_json::Value = serde_json::from_slice(&decoded_claims)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid JSON in JWT payload: {}", e)))?;
let iss = claims["iss"].as_str().ok_or((StatusCode::BAD_REQUEST, "Missing iss claim".to_string()))?;
let aud_val = &claims["aud"];
let aud = match aud_val {
serde_json::Value::String(s) => s.as_str(),
serde_json::Value::Array(arr) => arr[0].as_str().ok_or((StatusCode::BAD_REQUEST, "Invalid aud in array".to_string()))?,
_ => return Err((StatusCode::BAD_REQUEST, "Invalid aud claim".to_string())),
};
// 2. Find registration
let registration = sqlx::query_as::<_, LtiRegistration>(
"SELECT * FROM lti_registrations WHERE issuer = $1 AND client_id = $2"
)
.bind(iss)
.bind(aud)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "LTI Registration not found for issuer/aud".to_string()))?;
// 3. Validate JWT
let lti_claims = validate_lti_jwt(&payload.id_token, &registration.jwks_url, &registration.client_id)
.await
.map_err(|e| (StatusCode::UNAUTHORIZED, format!("JWT validation failed: {}", e)))?;
// 4. Verify nonce
let nonce_exists = sqlx::query("DELETE FROM lti_nonces WHERE nonce = $1")
.bind(&lti_claims.nonce)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.rows_affected() > 0;
if !nonce_exists {
return Err((StatusCode::BAD_REQUEST, "Invalid or expired nonce".to_string()));
}
// 5. Find or create user
let email = lti_claims.email.clone().unwrap_or_else(|| format!("lti_{}@{}", lti_claims.subject, iss.replace("http://", "").replace("https://", "")));
let full_name = lti_claims.name.clone().unwrap_or_else(|| "LTI User".to_string());
let mut user = sqlx::query_as::<_, User>(
"SELECT * FROM users WHERE email = $1 AND organization_id = $2"
)
.bind(&email)
.bind(registration.organization_id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if user.is_none() {
let new_user_id = Uuid::new_v4();
let role = if lti_claims.roles.iter().any(|r| r.contains("Instructor") || r.contains("Administrator")) {
"instructor"
} else {
"student"
};
sqlx::query(
"INSERT INTO users (id, organization_id, email, password_hash, full_name, role) VALUES ($1, $2, $3, $4, $5, $6)"
)
.bind(new_user_id)
.bind(registration.organization_id)
.bind(&email)
.bind("")
.bind(&full_name)
.bind(role)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
user = Some(User {
id: new_user_id,
organization_id: registration.organization_id,
email: email.clone(),
password_hash: "".to_string(),
full_name: full_name.clone(),
role: role.to_string(),
xp: 0,
level: 1,
avatar_url: None,
bio: None,
language: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
});
}
let user = user.unwrap();
// 6. Map resource link to course
let resource_link = sqlx::query_as::<_, LtiResourceLink>(
"SELECT * FROM lti_resource_links WHERE organization_id = $1 AND resource_link_id = $2"
)
.bind(registration.organization_id)
.bind(&lti_claims.resource_link.id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let redirect_target = if let Some(link) = resource_link {
sqlx::query(
"INSERT INTO enrollments (user_id, organization_id, course_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING"
)
.bind(user.id)
.bind(registration.organization_id)
.bind(link.course_id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
format!("/courses/{}", link.course_id)
} else {
"/dashboard".to_string()
};
// 7. Generate JWT
let claims = Claims {
sub: user.id,
role: user.role,
org: user.organization_id,
exp: (chrono::Utc::now() + chrono::Duration::hours(24)).timestamp(),
course_id: None,
token_type: Some("access".to_string()),
};
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
let token = jsonwebtoken::encode(
&jsonwebtoken::Header::default(),
&claims,
&jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 8. Redirect to Experience app launch page
let experience_url = std::env::var("NEXT_PUBLIC_EXPERIENCE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
Ok(Redirect::to(&format!("{}/lti/launch?token={}&target={}", experience_url, token, urlencoding::encode(&redirect_target))))
}