Skip to content

Commit e43999e

Browse files
authored
Development: Adapt LTI advantage deep linking service for exercise selection from Moodle (#7425)
1 parent a85d19d commit e43999e

31 files changed

+1796
-99
lines changed

src/main/java/de/tum/in/www1/artemis/config/lti/CustomLti13Configurer.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,27 @@
2222
@Profile("lti")
2323
public class CustomLti13Configurer extends Lti13Configurer {
2424

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

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

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

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

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

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

43+
/** Path for LTI 1.3 deep linking. */
44+
public static final String LTI13_DEEPLINKING_PATH = "/lti/deep-linking/";
45+
3746
public CustomLti13Configurer() {
3847
super.ltiPath(LTI13_BASE_PATH);
3948
super.loginInitiationPath(LOGIN_INITIATION_PATH);

src/main/java/de/tum/in/www1/artemis/domain/lti/Claims.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,15 @@
22

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

5+
/**
6+
* Constant for LTI Assignment and Grade Services (AGS) claim endpoint.
7+
* Used to identify the AGS service endpoint in LTI messages.
8+
*/
59
public static final String AGS_CLAIM = "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint";
10+
11+
/**
12+
* Constant for LTI Deep Linking message claim.
13+
* Used to carry messages specific to LTI Deep Linking requests and responses.
14+
*/
15+
public static final String MSG = "https://purl.imsglobal.org/spec/lti-dl/claim/msg";
616
}

src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13ClientRegistration.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public Lti13ClientRegistration(String serverUrl, Course course, String clientReg
5959
toolConfiguration.setDomain(domain);
6060
toolConfiguration.setTargetLinkUri(serverUrl + "/courses/" + course.getId());
6161
toolConfiguration.setClaims(Arrays.asList("iss", "email", "sub", "name", "given_name", "family_name"));
62-
Message deepLinkingMessage = new Message("LtiDeepLinkingRequest", serverUrl + CustomLti13Configurer.LTI13_BASE_PATH + "/deep-linking/" + course.getId());
62+
Message deepLinkingMessage = new Message("LtiDeepLinkingRequest", serverUrl + CustomLti13Configurer.LTI13_DEEPLINKING_PATH + course.getId());
6363
toolConfiguration.setMessages(List.of(deepLinkingMessage));
6464
this.setLti13ToolConfiguration(toolConfiguration);
6565
}
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
package de.tum.in.www1.artemis.domain.lti;
2+
3+
import java.util.HashMap;
4+
import java.util.Map;
5+
6+
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
7+
8+
import com.fasterxml.jackson.annotation.JsonProperty;
9+
import com.google.gson.JsonObject;
10+
import com.google.gson.JsonParser;
11+
12+
/**
13+
* Represents the LTI 1.3 Deep Linking Response.
14+
* It encapsulates the necessary information to construct a valid deep linking response
15+
* according to the LTI 1.3 specification.
16+
*/
17+
public class Lti13DeepLinkingResponse {
18+
19+
@JsonProperty("aud")
20+
private String aud;
21+
22+
@JsonProperty("iss")
23+
private String iss;
24+
25+
@JsonProperty("exp")
26+
private String exp;
27+
28+
@JsonProperty("iat")
29+
private String iat;
30+
31+
@JsonProperty("nonce")
32+
private String nonce;
33+
34+
@JsonProperty(Claims.MSG)
35+
private String message;
36+
37+
@JsonProperty(Claims.LTI_DEPLOYMENT_ID)
38+
private String deploymentId;
39+
40+
@JsonProperty(Claims.MESSAGE_TYPE)
41+
private String messageType;
42+
43+
@JsonProperty(Claims.LTI_VERSION)
44+
private String ltiVersion;
45+
46+
@JsonProperty(Claims.CONTENT_ITEMS)
47+
private String contentItems;
48+
49+
private JsonObject deepLinkingSettings;
50+
51+
private String clientRegistrationId;
52+
53+
private String returnUrl;
54+
55+
/**
56+
* Constructs an Lti13DeepLinkingResponse from an OIDC ID token and client registration ID.
57+
*
58+
* @param ltiIdToken the OIDC ID token
59+
* @param clientRegistrationId the client registration ID
60+
*/
61+
public Lti13DeepLinkingResponse(OidcIdToken ltiIdToken, String clientRegistrationId) {
62+
validateClaims(ltiIdToken);
63+
64+
this.deepLinkingSettings = JsonParser.parseString(ltiIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS).toString()).getAsJsonObject();
65+
this.setReturnUrl(this.deepLinkingSettings.get("deep_link_return_url").getAsString());
66+
this.clientRegistrationId = clientRegistrationId;
67+
68+
this.setAud(ltiIdToken.getClaim("iss").toString());
69+
this.setIss(ltiIdToken.getClaim("aud").toString().replace("[", "").replace("]", ""));
70+
this.setExp(ltiIdToken.getClaim("exp").toString());
71+
this.setIat(ltiIdToken.getClaim("iat").toString());
72+
this.setNonce(ltiIdToken.getClaim("nonce").toString());
73+
this.setMessage("Content successfully linked");
74+
this.setDeploymentId(ltiIdToken.getClaim(Claims.LTI_DEPLOYMENT_ID).toString());
75+
this.setMessageType("LtiDeepLinkingResponse");
76+
this.setLtiVersion("1.3.0");
77+
}
78+
79+
/**
80+
* Retrieves a map of claims to be included in the ID token.
81+
*
82+
* @return a map of claims
83+
*/
84+
public Map<String, Object> getClaims() {
85+
Map<String, Object> claims = new HashMap<>();
86+
87+
claims.put("aud", aud);
88+
claims.put("iss", iss);
89+
claims.put("exp", exp);
90+
claims.put("iat", iat);
91+
claims.put("nonce", nonce);
92+
claims.put(Claims.MSG, message);
93+
claims.put(Claims.LTI_DEPLOYMENT_ID, deploymentId);
94+
claims.put(Claims.MESSAGE_TYPE, messageType);
95+
claims.put(Claims.LTI_VERSION, ltiVersion);
96+
claims.put(Claims.CONTENT_ITEMS, contentItems);
97+
98+
return claims;
99+
}
100+
101+
private void validateClaims(OidcIdToken ltiIdToken) {
102+
if (ltiIdToken == null) {
103+
throw new IllegalArgumentException("The OIDC ID token must not be null.");
104+
}
105+
106+
Object deepLinkingSettingsElement = ltiIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS);
107+
if (deepLinkingSettingsElement == null) {
108+
throw new IllegalArgumentException("Missing or invalid deep linking settings in ID token.");
109+
}
110+
111+
ensureClaimPresent(ltiIdToken, "iss");
112+
ensureClaimPresent(ltiIdToken, "aud");
113+
ensureClaimPresent(ltiIdToken, "exp");
114+
ensureClaimPresent(ltiIdToken, "iat");
115+
ensureClaimPresent(ltiIdToken, "nonce");
116+
ensureClaimPresent(ltiIdToken, Claims.LTI_DEPLOYMENT_ID);
117+
}
118+
119+
private void ensureClaimPresent(OidcIdToken ltiIdToken, String claimName) {
120+
Object claimValue = ltiIdToken.getClaim(claimName);
121+
if (claimValue == null) {
122+
throw new IllegalArgumentException("Missing claim: " + claimName);
123+
}
124+
}
125+
126+
public void setAud(String aud) {
127+
this.aud = aud;
128+
}
129+
130+
public String getIss() {
131+
return iss;
132+
}
133+
134+
public void setIss(String iss) {
135+
this.iss = iss;
136+
}
137+
138+
public String getExp() {
139+
return exp;
140+
}
141+
142+
public void setExp(String exp) {
143+
this.exp = exp;
144+
}
145+
146+
public String getIat() {
147+
return iat;
148+
}
149+
150+
public void setIat(String iat) {
151+
this.iat = iat;
152+
}
153+
154+
public String getNonce() {
155+
return nonce;
156+
}
157+
158+
public void setNonce(String nonce) {
159+
this.nonce = nonce;
160+
}
161+
162+
public String getMessage() {
163+
return message;
164+
}
165+
166+
public void setMessage(String message) {
167+
this.message = message;
168+
}
169+
170+
public String getDeploymentId() {
171+
return deploymentId;
172+
}
173+
174+
public void setDeploymentId(String deploymentId) {
175+
this.deploymentId = deploymentId;
176+
}
177+
178+
public String getMessageType() {
179+
return messageType;
180+
}
181+
182+
public void setMessageType(String messageType) {
183+
this.messageType = messageType;
184+
}
185+
186+
public String getLtiVersion() {
187+
return ltiVersion;
188+
}
189+
190+
public void setLtiVersion(String ltiVersion) {
191+
this.ltiVersion = ltiVersion;
192+
}
193+
194+
public String getContentItems() {
195+
return contentItems;
196+
}
197+
198+
public void setContentItems(String contentItems) {
199+
this.contentItems = contentItems;
200+
}
201+
202+
public JsonObject getDeepLinkingSettings() {
203+
return deepLinkingSettings;
204+
}
205+
206+
public void setDeepLinkingSettings(JsonObject deepLinkingSettings) {
207+
this.deepLinkingSettings = deepLinkingSettings;
208+
}
209+
210+
public String getClientRegistrationId() {
211+
return clientRegistrationId;
212+
}
213+
214+
public void setClientRegistrationId(String clientRegistrationId) {
215+
this.clientRegistrationId = clientRegistrationId;
216+
}
217+
218+
public String getAud() {
219+
return aud;
220+
}
221+
222+
public String getReturnUrl() {
223+
return returnUrl;
224+
}
225+
226+
public void setReturnUrl(String returnUrl) {
227+
this.returnUrl = returnUrl;
228+
}
229+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package de.tum.in.www1.artemis.domain.lti;
2+
3+
/**
4+
* Holds LTI authentication response details.
5+
*
6+
* @param targetLinkUri URI targeted in the LTI process.
7+
* @param ltiIdToken LTI service provided ID token.
8+
* @param clientRegistrationId Client's registration ID with LTI service.
9+
*/
10+
public record LtiAuthenticationResponseDTO(String targetLinkUri, String ltiIdToken, String clientRegistrationId) {
11+
}

src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@
1919
import org.springframework.web.filter.OncePerRequestFilter;
2020
import org.springframework.web.util.UriComponentsBuilder;
2121

22-
import com.google.gson.JsonObject;
22+
import com.google.gson.Gson;
2323

2424
import de.tum.in.www1.artemis.domain.lti.Claims;
25+
import de.tum.in.www1.artemis.domain.lti.LtiAuthenticationResponseDTO;
2526
import de.tum.in.www1.artemis.exception.LtiEmailAlreadyInUseException;
27+
import de.tum.in.www1.artemis.security.SecurityUtils;
2628
import de.tum.in.www1.artemis.service.connectors.lti.Lti13Service;
2729
import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.authentication.OidcAuthenticationToken;
2830
import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web.OAuth2LoginAuthenticationFilter;
@@ -56,32 +58,37 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
5658
return;
5759
}
5860

59-
// Initialize targetLink as an empty string here to ensure it has a value even if an exception is caught later.
60-
String targetLink = "";
61-
OidcIdToken ltiIdToken = null;
6261
try {
6362
OidcAuthenticationToken authToken = finishOidcFlow(request, response);
63+
OidcIdToken ltiIdToken = ((OidcUser) authToken.getPrincipal()).getIdToken();
64+
String targetLink = ltiIdToken.getClaim(Claims.TARGET_LINK_URI).toString();
65+
66+
try {
67+
// here we need to check if this is a deep-linking request or a launch request
68+
if ("LtiDeepLinkingRequest".equals(ltiIdToken.getClaim(Claims.MESSAGE_TYPE))) {
69+
lti13Service.startDeepLinking(ltiIdToken);
70+
}
71+
else {
72+
lti13Service.performLaunch(ltiIdToken, authToken.getAuthorizedClientRegistrationId());
73+
}
74+
}
75+
catch (LtiEmailAlreadyInUseException ex) {
76+
// LtiEmailAlreadyInUseException is thrown in case of user who has email address in use is not authenticated after targetLink is set
77+
// We need targetLink to redirect user on the client-side after successful authentication
78+
handleLtiEmailAlreadyInUseException(response, ltiIdToken);
79+
}
6480

65-
ltiIdToken = ((OidcUser) authToken.getPrincipal()).getIdToken();
66-
67-
targetLink = ltiIdToken.getClaim(Claims.TARGET_LINK_URI).toString();
68-
69-
lti13Service.performLaunch(ltiIdToken, authToken.getAuthorizedClientRegistrationId());
70-
71-
writeResponse(ltiIdToken.getClaim(Claims.TARGET_LINK_URI), response);
81+
writeResponse(targetLink, ltiIdToken, authToken.getAuthorizedClientRegistrationId(), response);
7282
}
7383
catch (HttpClientErrorException | OAuth2AuthenticationException | IllegalStateException ex) {
7484
log.error("Error during LTI 1.3 launch request: {}", ex.getMessage());
7585
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "LTI 1.3 Launch failed");
7686
}
77-
catch (LtiEmailAlreadyInUseException ex) {
78-
// LtiEmailAlreadyInUseException is thrown in case of user who has email address in use is not authenticated after targetLink is set
79-
// We need targetLink to redirect user on the client-side after successful authentication
80-
UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(targetLink);
81-
lti13Service.buildLtiEmailInUseResponse(response, ltiIdToken);
82-
response.setHeader("TargetLinkUri", uriBuilder.build().toUriString());
83-
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "LTI 1.3 user authentication failed");
84-
}
87+
}
88+
89+
private void handleLtiEmailAlreadyInUseException(HttpServletResponse response, OidcIdToken ltiIdToken) {
90+
this.lti13Service.buildLtiEmailInUseResponse(response, ltiIdToken);
91+
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
8592
}
8693

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

103-
private void writeResponse(String targetLinkUri, HttpServletResponse response) throws IOException {
110+
private void writeResponse(String targetLinkUri, OidcIdToken ltiIdToken, String clientRegistrationId, HttpServletResponse response) throws IOException {
104111
PrintWriter writer = response.getWriter();
105112

106113
UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(targetLinkUri);
107-
lti13Service.buildLtiResponse(uriBuilder, response);
108-
109-
JsonObject json = new JsonObject();
110-
json.addProperty("targetLinkUri", uriBuilder.build().toUriString());
114+
if (SecurityUtils.isAuthenticated()) {
115+
lti13Service.buildLtiResponse(uriBuilder, response);
116+
}
117+
LtiAuthenticationResponseDTO jsonResponse = new LtiAuthenticationResponseDTO(uriBuilder.build().toUriString(), ltiIdToken.getTokenValue(), clientRegistrationId);
111118

112119
response.setContentType("application/json");
113120
response.setCharacterEncoding("UTF-8");
114-
writer.print(json);
121+
writer.print(new Gson().toJson(jsonResponse));
115122
writer.flush();
116123
}
117124
}

0 commit comments

Comments
 (0)