Skip to content

Commit

Permalink
Development: Adapt LTI advantage deep linking service for exercise se…
Browse files Browse the repository at this point in the history
…lection from Moodle (#7425)
  • Loading branch information
basak-akan authored Dec 4, 2023
1 parent a85d19d commit e43999e
Show file tree
Hide file tree
Showing 31 changed files with 1,796 additions and 99 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,27 @@
@Profile("lti")
public class CustomLti13Configurer extends Lti13Configurer {

/** Path for login. **/
private static final String LOGIN_PATH = "/auth-login";

/** Path for initiating login process. */
private static final String LOGIN_INITIATION_PATH = "/initiate-login";

/** Base path for LTI 1.3 API endpoints. */
public static final String LTI13_BASE_PATH = "/api/public/lti13";

/** Full path for LTI 1.3 login. */
public static final String LTI13_LOGIN_PATH = LTI13_BASE_PATH + LOGIN_PATH;

/** Full path for LTI 1.3 login initiation. */
public static final String LTI13_LOGIN_INITIATION_PATH = LTI13_BASE_PATH + LOGIN_INITIATION_PATH;

/** Redirect proxy path for LTI 1.3 login. */
public static final String LTI13_LOGIN_REDIRECT_PROXY_PATH = LTI13_BASE_PATH + "/auth-callback";

/** Path for LTI 1.3 deep linking. */
public static final String LTI13_DEEPLINKING_PATH = "/lti/deep-linking/";

public CustomLti13Configurer() {
super.ltiPath(LTI13_BASE_PATH);
super.loginInitiationPath(LOGIN_INITIATION_PATH);
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/de/tum/in/www1/artemis/domain/lti/Claims.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,15 @@

public class Claims extends uk.ac.ox.ctl.lti13.lti.Claims {

/**
* Constant for LTI Assignment and Grade Services (AGS) claim endpoint.
* Used to identify the AGS service endpoint in LTI messages.
*/
public static final String AGS_CLAIM = "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint";

/**
* Constant for LTI Deep Linking message claim.
* Used to carry messages specific to LTI Deep Linking requests and responses.
*/
public static final String MSG = "https://purl.imsglobal.org/spec/lti-dl/claim/msg";
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public Lti13ClientRegistration(String serverUrl, Course course, String clientReg
toolConfiguration.setDomain(domain);
toolConfiguration.setTargetLinkUri(serverUrl + "/courses/" + course.getId());
toolConfiguration.setClaims(Arrays.asList("iss", "email", "sub", "name", "given_name", "family_name"));
Message deepLinkingMessage = new Message("LtiDeepLinkingRequest", serverUrl + CustomLti13Configurer.LTI13_BASE_PATH + "/deep-linking/" + course.getId());
Message deepLinkingMessage = new Message("LtiDeepLinkingRequest", serverUrl + CustomLti13Configurer.LTI13_DEEPLINKING_PATH + course.getId());
toolConfiguration.setMessages(List.of(deepLinkingMessage));
this.setLti13ToolConfiguration(toolConfiguration);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
package de.tum.in.www1.artemis.domain.lti;

import java.util.HashMap;
import java.util.Map;

import org.springframework.security.oauth2.core.oidc.OidcIdToken;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;

/**
* Represents the LTI 1.3 Deep Linking Response.
* It encapsulates the necessary information to construct a valid deep linking response
* according to the LTI 1.3 specification.
*/
public class Lti13DeepLinkingResponse {

@JsonProperty("aud")
private String aud;

@JsonProperty("iss")
private String iss;

@JsonProperty("exp")
private String exp;

@JsonProperty("iat")
private String iat;

@JsonProperty("nonce")
private String nonce;

@JsonProperty(Claims.MSG)
private String message;

@JsonProperty(Claims.LTI_DEPLOYMENT_ID)
private String deploymentId;

@JsonProperty(Claims.MESSAGE_TYPE)
private String messageType;

@JsonProperty(Claims.LTI_VERSION)
private String ltiVersion;

@JsonProperty(Claims.CONTENT_ITEMS)
private String contentItems;

private JsonObject deepLinkingSettings;

private String clientRegistrationId;

private String returnUrl;

/**
* Constructs an Lti13DeepLinkingResponse from an OIDC ID token and client registration ID.
*
* @param ltiIdToken the OIDC ID token
* @param clientRegistrationId the client registration ID
*/
public Lti13DeepLinkingResponse(OidcIdToken ltiIdToken, String clientRegistrationId) {
validateClaims(ltiIdToken);

this.deepLinkingSettings = JsonParser.parseString(ltiIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS).toString()).getAsJsonObject();
this.setReturnUrl(this.deepLinkingSettings.get("deep_link_return_url").getAsString());
this.clientRegistrationId = clientRegistrationId;

this.setAud(ltiIdToken.getClaim("iss").toString());
this.setIss(ltiIdToken.getClaim("aud").toString().replace("[", "").replace("]", ""));
this.setExp(ltiIdToken.getClaim("exp").toString());
this.setIat(ltiIdToken.getClaim("iat").toString());
this.setNonce(ltiIdToken.getClaim("nonce").toString());
this.setMessage("Content successfully linked");
this.setDeploymentId(ltiIdToken.getClaim(Claims.LTI_DEPLOYMENT_ID).toString());
this.setMessageType("LtiDeepLinkingResponse");
this.setLtiVersion("1.3.0");
}

/**
* Retrieves a map of claims to be included in the ID token.
*
* @return a map of claims
*/
public Map<String, Object> getClaims() {
Map<String, Object> claims = new HashMap<>();

claims.put("aud", aud);
claims.put("iss", iss);
claims.put("exp", exp);
claims.put("iat", iat);
claims.put("nonce", nonce);
claims.put(Claims.MSG, message);
claims.put(Claims.LTI_DEPLOYMENT_ID, deploymentId);
claims.put(Claims.MESSAGE_TYPE, messageType);
claims.put(Claims.LTI_VERSION, ltiVersion);
claims.put(Claims.CONTENT_ITEMS, contentItems);

return claims;
}

private void validateClaims(OidcIdToken ltiIdToken) {
if (ltiIdToken == null) {
throw new IllegalArgumentException("The OIDC ID token must not be null.");
}

Object deepLinkingSettingsElement = ltiIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS);
if (deepLinkingSettingsElement == null) {
throw new IllegalArgumentException("Missing or invalid deep linking settings in ID token.");
}

ensureClaimPresent(ltiIdToken, "iss");
ensureClaimPresent(ltiIdToken, "aud");
ensureClaimPresent(ltiIdToken, "exp");
ensureClaimPresent(ltiIdToken, "iat");
ensureClaimPresent(ltiIdToken, "nonce");
ensureClaimPresent(ltiIdToken, Claims.LTI_DEPLOYMENT_ID);
}

private void ensureClaimPresent(OidcIdToken ltiIdToken, String claimName) {
Object claimValue = ltiIdToken.getClaim(claimName);
if (claimValue == null) {
throw new IllegalArgumentException("Missing claim: " + claimName);
}
}

public void setAud(String aud) {
this.aud = aud;
}

public String getIss() {
return iss;
}

public void setIss(String iss) {
this.iss = iss;
}

public String getExp() {
return exp;
}

public void setExp(String exp) {
this.exp = exp;
}

public String getIat() {
return iat;
}

public void setIat(String iat) {
this.iat = iat;
}

public String getNonce() {
return nonce;
}

public void setNonce(String nonce) {
this.nonce = nonce;
}

public String getMessage() {
return message;
}

public void setMessage(String message) {
this.message = message;
}

public String getDeploymentId() {
return deploymentId;
}

public void setDeploymentId(String deploymentId) {
this.deploymentId = deploymentId;
}

public String getMessageType() {
return messageType;
}

public void setMessageType(String messageType) {
this.messageType = messageType;
}

public String getLtiVersion() {
return ltiVersion;
}

public void setLtiVersion(String ltiVersion) {
this.ltiVersion = ltiVersion;
}

public String getContentItems() {
return contentItems;
}

public void setContentItems(String contentItems) {
this.contentItems = contentItems;
}

public JsonObject getDeepLinkingSettings() {
return deepLinkingSettings;
}

public void setDeepLinkingSettings(JsonObject deepLinkingSettings) {
this.deepLinkingSettings = deepLinkingSettings;
}

public String getClientRegistrationId() {
return clientRegistrationId;
}

public void setClientRegistrationId(String clientRegistrationId) {
this.clientRegistrationId = clientRegistrationId;
}

public String getAud() {
return aud;
}

public String getReturnUrl() {
return returnUrl;
}

public void setReturnUrl(String returnUrl) {
this.returnUrl = returnUrl;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package de.tum.in.www1.artemis.domain.lti;

/**
* Holds LTI authentication response details.
*
* @param targetLinkUri URI targeted in the LTI process.
* @param ltiIdToken LTI service provided ID token.
* @param clientRegistrationId Client's registration ID with LTI service.
*/
public record LtiAuthenticationResponseDTO(String targetLinkUri, String ltiIdToken, String clientRegistrationId) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.UriComponentsBuilder;

import com.google.gson.JsonObject;
import com.google.gson.Gson;

import de.tum.in.www1.artemis.domain.lti.Claims;
import de.tum.in.www1.artemis.domain.lti.LtiAuthenticationResponseDTO;
import de.tum.in.www1.artemis.exception.LtiEmailAlreadyInUseException;
import de.tum.in.www1.artemis.security.SecurityUtils;
import de.tum.in.www1.artemis.service.connectors.lti.Lti13Service;
import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.authentication.OidcAuthenticationToken;
import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web.OAuth2LoginAuthenticationFilter;
Expand Down Expand Up @@ -56,32 +58,37 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
return;
}

// Initialize targetLink as an empty string here to ensure it has a value even if an exception is caught later.
String targetLink = "";
OidcIdToken ltiIdToken = null;
try {
OidcAuthenticationToken authToken = finishOidcFlow(request, response);
OidcIdToken ltiIdToken = ((OidcUser) authToken.getPrincipal()).getIdToken();
String targetLink = ltiIdToken.getClaim(Claims.TARGET_LINK_URI).toString();

try {
// here we need to check if this is a deep-linking request or a launch request
if ("LtiDeepLinkingRequest".equals(ltiIdToken.getClaim(Claims.MESSAGE_TYPE))) {
lti13Service.startDeepLinking(ltiIdToken);
}
else {
lti13Service.performLaunch(ltiIdToken, authToken.getAuthorizedClientRegistrationId());
}
}
catch (LtiEmailAlreadyInUseException ex) {
// LtiEmailAlreadyInUseException is thrown in case of user who has email address in use is not authenticated after targetLink is set
// We need targetLink to redirect user on the client-side after successful authentication
handleLtiEmailAlreadyInUseException(response, ltiIdToken);
}

ltiIdToken = ((OidcUser) authToken.getPrincipal()).getIdToken();

targetLink = ltiIdToken.getClaim(Claims.TARGET_LINK_URI).toString();

lti13Service.performLaunch(ltiIdToken, authToken.getAuthorizedClientRegistrationId());

writeResponse(ltiIdToken.getClaim(Claims.TARGET_LINK_URI), response);
writeResponse(targetLink, ltiIdToken, authToken.getAuthorizedClientRegistrationId(), response);
}
catch (HttpClientErrorException | OAuth2AuthenticationException | IllegalStateException ex) {
log.error("Error during LTI 1.3 launch request: {}", ex.getMessage());
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "LTI 1.3 Launch failed");
}
catch (LtiEmailAlreadyInUseException ex) {
// LtiEmailAlreadyInUseException is thrown in case of user who has email address in use is not authenticated after targetLink is set
// We need targetLink to redirect user on the client-side after successful authentication
UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(targetLink);
lti13Service.buildLtiEmailInUseResponse(response, ltiIdToken);
response.setHeader("TargetLinkUri", uriBuilder.build().toUriString());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "LTI 1.3 user authentication failed");
}
}

private void handleLtiEmailAlreadyInUseException(HttpServletResponse response, OidcIdToken ltiIdToken) {
this.lti13Service.buildLtiEmailInUseResponse(response, ltiIdToken);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
}

private OidcAuthenticationToken finishOidcFlow(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
Expand All @@ -100,18 +107,18 @@ private OidcAuthenticationToken finishOidcFlow(HttpServletRequest request, HttpS
return ltiAuthToken;
}

private void writeResponse(String targetLinkUri, HttpServletResponse response) throws IOException {
private void writeResponse(String targetLinkUri, OidcIdToken ltiIdToken, String clientRegistrationId, HttpServletResponse response) throws IOException {
PrintWriter writer = response.getWriter();

UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(targetLinkUri);
lti13Service.buildLtiResponse(uriBuilder, response);

JsonObject json = new JsonObject();
json.addProperty("targetLinkUri", uriBuilder.build().toUriString());
if (SecurityUtils.isAuthenticated()) {
lti13Service.buildLtiResponse(uriBuilder, response);
}
LtiAuthenticationResponseDTO jsonResponse = new LtiAuthenticationResponseDTO(uriBuilder.build().toUriString(), ltiIdToken.getTokenValue(), clientRegistrationId);

response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
writer.print(json);
writer.print(new Gson().toJson(jsonResponse));
writer.flush();
}
}
Loading

0 comments on commit e43999e

Please sign in to comment.