diff --git a/src/main/java/de/tum/in/www1/artemis/config/lti/CustomLti13Configurer.java b/src/main/java/de/tum/in/www1/artemis/config/lti/CustomLti13Configurer.java index dd4f1de01542..15adf5c22a34 100644 --- a/src/main/java/de/tum/in/www1/artemis/config/lti/CustomLti13Configurer.java +++ b/src/main/java/de/tum/in/www1/artemis/config/lti/CustomLti13Configurer.java @@ -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); diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lti/Claims.java b/src/main/java/de/tum/in/www1/artemis/domain/lti/Claims.java index 1228b5a55686..be99b242aed2 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/lti/Claims.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/lti/Claims.java @@ -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"; } diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13ClientRegistration.java b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13ClientRegistration.java index 9d01e9966817..05fe84d03eef 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13ClientRegistration.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13ClientRegistration.java @@ -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); } diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java new file mode 100644 index 000000000000..5b79999a21e2 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java @@ -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 getClaims() { + Map 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; + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lti/LtiAuthenticationResponseDTO.java b/src/main/java/de/tum/in/www1/artemis/domain/lti/LtiAuthenticationResponseDTO.java new file mode 100644 index 000000000000..899db58f27d8 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/domain/lti/LtiAuthenticationResponseDTO.java @@ -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) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java index b9d7b7567bfb..47a3b9f73793 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java @@ -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; @@ -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 { @@ -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(); } } diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetriever.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetriever.java index 0d015b913ea1..b1492c356dbd 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetriever.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetriever.java @@ -87,6 +87,48 @@ public String getToken(ClientRegistration clientRegistration, String... scopes) } } + /** + * Creates a signed JWT for LTI Deep Linking using a specific client registration ID and a set of custom claims. + * The JWT is signed using the RSA algorithm with SHA-256. + * + * @param clientRegistrationId The client registration ID associated with the JWK to be used for signing the JWT. + * @param customClaims A map of custom claims to be included in the JWT. These claims are additional data + * that the consuming service may require. + * @return A serialized signed JWT as a String. + * @throws IllegalArgumentException If no JWK could be retrieved for the provided client registration ID. + * @throws JOSEException If there is an error creating the RSA key pair or signing the JWT. + */ + public String createDeepLinkingJWT(String clientRegistrationId, Map customClaims) { + JWK jwk = oAuth2JWKSService.getJWK(clientRegistrationId); + + if (jwk == null) { + throw new IllegalArgumentException("Failed to get JWK for client registration: " + clientRegistrationId); + } + + try { + KeyPair keyPair = jwk.toRSAKey().toKeyPair(); + RSASSASigner signer = new RSASSASigner(keyPair.getPrivate()); + + var claimSetBuilder = new JWTClaimsSet.Builder(); + for (Map.Entry entry : customClaims.entrySet()) { + claimSetBuilder.claim(entry.getKey(), entry.getValue()); + } + + JWTClaimsSet claimsSet = claimSetBuilder.issueTime(Date.from(Instant.now())).expirationTime(Date.from(Instant.now().plusSeconds(JWT_LIFETIME))).build(); + + JWSHeader jwt = new JWSHeader.Builder(JWSAlgorithm.RS256).type(JOSEObjectType.JWT).keyID(jwk.getKeyID()).build(); + SignedJWT signedJWT = new SignedJWT(jwt, claimsSet); + signedJWT.sign(signer); + + log.debug("Created signed token: {}", signedJWT.serialize()); + return signedJWT.serialize(); + } + catch (JOSEException e) { + log.error("Could not create keypair for clientRegistrationId {}", clientRegistrationId); + return null; + } + } + private SignedJWT createJWT(ClientRegistration clientRegistration) { JWK jwk = oAuth2JWKSService.getJWK(clientRegistration.getRegistrationId()); diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java index 9f6a4cab55a7..a63bf701279b 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java @@ -44,6 +44,8 @@ public class Lti13Service { private static final String EXERCISE_PATH_PATTERN = "/courses/{courseId}/exercises/{exerciseId}"; + private static final String COURSE_PATH_PATTERN = "/lti/deep-linking/{courseId}"; + private final Logger log = LoggerFactory.getLogger(Lti13Service.class); private final UserRepository userRepository; @@ -284,6 +286,29 @@ private Optional getExerciseFromTargetLink(String targetLinkUrl) { return exerciseOpt; } + private Course getCourseFromTargetLink(String targetLinkUrl) { + AntPathMatcher matcher = new AntPathMatcher(); + + String targetLinkPath; + try { + targetLinkPath = (new URL(targetLinkUrl)).getPath(); + } + catch (MalformedURLException ex) { + log.info("Malformed target link url: {}", targetLinkUrl); + return null; + } + + if (!matcher.match(COURSE_PATH_PATTERN, targetLinkPath)) { + log.info("Could not extract courseId from target link: {}", targetLinkUrl); + return null; + } + Map pathVariables = matcher.extractUriTemplateVariables(COURSE_PATH_PATTERN, targetLinkPath); + + String courseId = pathVariables.get("courseId"); + + return courseRepository.findByIdWithEagerOnlineCourseConfigurationElseThrow(Long.parseLong(courseId)); + } + private void createOrUpdateResourceLaunch(Lti13LaunchRequest launchRequest, User user, Exercise exercise) { Optional launchOpt = launchRepository.findByIssAndSubAndDeploymentIdAndResourceLinkId(launchRequest.getIss(), launchRequest.getSub(), launchRequest.getDeploymentId(), launchRequest.getResourceLinkId()); @@ -331,4 +356,27 @@ private String getSanitizedUsername(String username) { return username.replaceAll("[\r\n]", ""); } + /** + * Initiates the deep linking process for a course based on the provided LTI ID token and client registration ID. + * + * @param ltiIdToken The ID token containing the deep linking information. + * @throws BadRequestAlertException if the course is not found or LTI is not configured for the course. + */ + public void startDeepLinking(OidcIdToken ltiIdToken) { + + String targetLinkUrl = ltiIdToken.getClaim(Claims.TARGET_LINK_URI); + Course targetCourse = getCourseFromTargetLink(targetLinkUrl); + if (targetCourse == null) { + log.error("No course to start deep-linking at {}", targetLinkUrl); + throw new BadRequestAlertException("Course not found", "LTI", "ltiCourseNotFound"); + } + + OnlineCourseConfiguration onlineCourseConfiguration = targetCourse.getOnlineCourseConfiguration(); + if (onlineCourseConfiguration == null) { + throw new BadRequestAlertException("LTI is not configured for this course", "LTI", "ltiNotConfigured"); + } + + ltiService.authenticateLtiUser(ltiIdToken.getEmail(), createUsernameFromLaunchRequest(ltiIdToken, onlineCourseConfiguration), ltiIdToken.getGivenName(), + ltiIdToken.getFamilyName(), onlineCourseConfiguration.isRequireExistingUser()); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java new file mode 100644 index 000000000000..0152727006d5 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java @@ -0,0 +1,133 @@ +package de.tum.in.www1.artemis.service.connectors.lti; + +import java.util.Optional; + +import org.glassfish.jersey.uri.UriComponent; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import de.tum.in.www1.artemis.domain.Exercise; +import de.tum.in.www1.artemis.domain.lti.Lti13DeepLinkingResponse; +import de.tum.in.www1.artemis.repository.ExerciseRepository; +import de.tum.in.www1.artemis.security.lti.Lti13TokenRetriever; +import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; + +/** + * Service for handling LTI deep linking functionality. + */ +@Service +@Profile("lti") +public class LtiDeepLinkingService { + + @Value("${server.url}") + private String artemisServerUrl; + + private final ExerciseRepository exerciseRepository; + + private final Lti13TokenRetriever tokenRetriever; + + /** + * Constructor for LtiDeepLinkingService. + * + * @param exerciseRepository The repository for exercises. + * @param tokenRetriever The LTI 1.3 token retriever. + */ + public LtiDeepLinkingService(ExerciseRepository exerciseRepository, Lti13TokenRetriever tokenRetriever) { + this.exerciseRepository = exerciseRepository; + this.tokenRetriever = tokenRetriever; + } + + /** + * Constructs an LTI Deep Linking response URL with JWT for the specified course and exercise. + * + * @param ltiIdToken OIDC ID token with the user's authentication claims. + * @param clientRegistrationId Client registration ID for the LTI tool. + * @param courseId ID of the course for deep linking. + * @param exerciseId ID of the exercise for deep linking. + * @return Constructed deep linking response URL. + * @throws BadRequestAlertException if there are issues with the OIDC ID token claims. + */ + public String performDeepLinking(OidcIdToken ltiIdToken, String clientRegistrationId, Long courseId, Long exerciseId) { + // Initialize DeepLinkingResponse + Lti13DeepLinkingResponse lti13DeepLinkingResponse = new Lti13DeepLinkingResponse(ltiIdToken, clientRegistrationId); + // Fill selected exercise link into content items + String contentItems = this.populateContentItems(String.valueOf(courseId), String.valueOf(exerciseId)); + lti13DeepLinkingResponse.setContentItems(contentItems); + + // Prepare return url with jwt and id parameters + return this.buildLtiDeepLinkResponse(clientRegistrationId, lti13DeepLinkingResponse); + } + + /** + * Build an LTI deep linking response URL. + * + * @return The LTI deep link response URL. + */ + private String buildLtiDeepLinkResponse(String clientRegistrationId, Lti13DeepLinkingResponse lti13DeepLinkingResponse) { + UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(this.artemisServerUrl + "/lti/select-content"); + + String jwt = tokenRetriever.createDeepLinkingJWT(clientRegistrationId, lti13DeepLinkingResponse.getClaims()); + String returnUrl = lti13DeepLinkingResponse.getReturnUrl(); + + // Validate properties are set to create a response + validateDeepLinkingResponseSettings(returnUrl, jwt, lti13DeepLinkingResponse.getDeploymentId()); + + uriComponentsBuilder.queryParam("jwt", jwt); + uriComponentsBuilder.queryParam("id", lti13DeepLinkingResponse.getDeploymentId()); + uriComponentsBuilder.queryParam("deepLinkUri", UriComponent.encode(returnUrl, UriComponent.Type.QUERY_PARAM)); + + return uriComponentsBuilder.build().toUriString(); + + } + + /** + * Populate content items for deep linking response. + * + * @param courseId The course ID. + * @param exerciseId The exercise ID. + */ + private String populateContentItems(String courseId, String exerciseId) { + JsonObject item = setContentItem(courseId, exerciseId); + JsonArray contentItems = new JsonArray(); + contentItems.add(item); + return contentItems.toString(); + } + + private JsonObject setContentItem(String courseId, String exerciseId) { + Optional exerciseOpt = exerciseRepository.findById(Long.valueOf(exerciseId)); + String launchUrl = String.format(artemisServerUrl + "/courses/%s/exercises/%s", courseId, exerciseId); + return exerciseOpt.map(exercise -> createContentItem(exercise.getType(), exercise.getTitle(), launchUrl)).orElse(null); + } + + private JsonObject createContentItem(String type, String title, String url) { + JsonObject item = new JsonObject(); + item.addProperty("type", type); + item.addProperty("title", title); + item.addProperty("url", url); + return item; + } + + private void validateDeepLinkingResponseSettings(String returnURL, String jwt, String deploymentId) { + if (isEmptyString(jwt)) { + throw new BadRequestAlertException("Deep linking response cannot be created", "LTI", "deepLinkingResponseFailed"); + } + + if (isEmptyString(returnURL)) { + throw new BadRequestAlertException("Cannot find platform return URL", "LTI", "deepLinkReturnURLEmpty"); + } + + if (isEmptyString(deploymentId)) { + throw new BadRequestAlertException("Platform deployment id cannot be empty", "LTI", "deploymentIdEmpty"); + } + } + + boolean isEmptyString(String string) { + return string == null || string.isEmpty(); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java index c2d644b36085..d676912a5a5d 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java @@ -1,17 +1,26 @@ package de.tum.in.www1.artemis.web.rest; +import java.text.ParseException; + import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.web.bind.annotation.*; +import com.google.gson.JsonObject; +import com.nimbusds.jwt.SignedJWT; + import de.tum.in.www1.artemis.domain.Course; import de.tum.in.www1.artemis.repository.CourseRepository; import de.tum.in.www1.artemis.security.Role; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastInstructor; import de.tum.in.www1.artemis.service.AuthorizationCheckService; +import de.tum.in.www1.artemis.service.connectors.lti.LtiDeepLinkingService; import de.tum.in.www1.artemis.service.connectors.lti.LtiDynamicRegistrationService; +import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; /** - * REST controller to handle LTI10 launches. + * REST controller to handle LTI13 launches. */ @RestController @RequestMapping("/api") @@ -20,16 +29,20 @@ public class LtiResource { private final LtiDynamicRegistrationService ltiDynamicRegistrationService; + private final LtiDeepLinkingService ltiDeepLinkingService; + private final CourseRepository courseRepository; private final AuthorizationCheckService authCheckService; public static final String LOGIN_REDIRECT_CLIENT_PATH = "/lti/launch"; - public LtiResource(LtiDynamicRegistrationService ltiDynamicRegistrationService, CourseRepository courseRepository, AuthorizationCheckService authCheckService) { + public LtiResource(LtiDynamicRegistrationService ltiDynamicRegistrationService, CourseRepository courseRepository, AuthorizationCheckService authCheckService, + LtiDeepLinkingService ltiDeepLinkingService) { this.ltiDynamicRegistrationService = ltiDynamicRegistrationService; this.courseRepository = courseRepository; this.authCheckService = authCheckService; + this.ltiDeepLinkingService = ltiDeepLinkingService; } @PostMapping("/lti13/dynamic-registration/{courseId}") @@ -41,4 +54,36 @@ public void lti13DynamicRegistration(@PathVariable Long courseId, @RequestParam( authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null); ltiDynamicRegistrationService.performDynamicRegistration(course, openIdConfiguration, registrationToken); } + + /** + * Handles the HTTP POST request for LTI 1.3 Deep Linking. This endpoint is used for deep linking of LTI links + * for exercises within a course. The method populates content items with the provided course and exercise identifiers, + * builds a deep linking response, and returns the target link URI in a JSON object. + * + * @param courseId The identifier of the course for which the deep linking is being performed. + * @param exerciseId The identifier of the exercise to be included in the deep linking response. + * @param ltiIdToken The token holding the deep linking information. + * @param clientRegistrationId The identifier online of the course configuration. + * @return A ResponseEntity containing a JSON object with the 'targetLinkUri' property set to the deep linking response target link. + */ + @PostMapping("/lti13/deep-linking/{courseId}") + @EnforceAtLeastInstructor + public ResponseEntity lti13DeepLinking(@PathVariable Long courseId, @RequestParam(name = "exerciseId") String exerciseId, + @RequestParam(name = "ltiIdToken") String ltiIdToken, @RequestParam(name = "clientRegistrationId") String clientRegistrationId) throws ParseException { + + Course course = courseRepository.findByIdWithEagerOnlineCourseConfigurationElseThrow(courseId); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null); + + if (!course.isOnlineCourse() || course.getOnlineCourseConfiguration() == null) { + throw new BadRequestAlertException("LTI is not configured for this course", "LTI", "ltiNotConfigured"); + } + + OidcIdToken idToken = new OidcIdToken(ltiIdToken, null, null, SignedJWT.parse(ltiIdToken).getJWTClaimsSet().getClaims()); + + String targetLink = ltiDeepLinkingService.performDeepLinking(idToken, clientRegistrationId, courseId, Long.valueOf(exerciseId)); + + JsonObject json = new JsonObject(); + json.addProperty("targetLinkUri", targetLink); + return ResponseEntity.ok(json.toString()); + } } diff --git a/src/main/webapp/app/course/manage/course-lti-configuration/course-lti-configuration.component.ts b/src/main/webapp/app/course/manage/course-lti-configuration/course-lti-configuration.component.ts index 47f8b19703e4..ef9e5aadfd5c 100644 --- a/src/main/webapp/app/course/manage/course-lti-configuration/course-lti-configuration.component.ts +++ b/src/main/webapp/app/course/manage/course-lti-configuration/course-lti-configuration.component.ts @@ -72,7 +72,7 @@ export class CourseLtiConfigurationComponent implements OnInit { * Gets the deep linking url */ getDeepLinkingUrl(): string { - return `${location.origin}/api/public/lti13/deep-linking/${this.course.id}`; // Needs to match url in CustomLti13Configurer + return `${location.origin}/lti/deep-linking/${this.course.id}`; // Needs to match url in CustomLti13Configurer } /** diff --git a/src/main/webapp/app/lti/lti.module.ts b/src/main/webapp/app/lti/lti.module.ts index a4615fa3b68e..0173267944ff 100644 --- a/src/main/webapp/app/lti/lti.module.ts +++ b/src/main/webapp/app/lti/lti.module.ts @@ -5,12 +5,16 @@ import { Lti13ExerciseLaunchComponent } from 'app/lti/lti13-exercise-launch.comp import { Lti13DynamicRegistrationComponent } from 'app/lti/lti13-dynamic-registration.component'; import { ArtemisCoreModule } from 'app/core/core.module'; import { ltiLaunchState } from './lti.route'; +import { Lti13DeepLinkingComponent } from 'app/lti/lti13-deep-linking.component'; +import { FormsModule } from '@angular/forms'; +import { Lti13SelectContentComponent } from 'app/lti/lti13-select-content.component'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; const LTI_LAUNCH_ROUTES = [...ltiLaunchState]; @NgModule({ - imports: [RouterModule.forChild(LTI_LAUNCH_ROUTES), ArtemisCoreModule, ArtemisSharedModule], - declarations: [Lti13ExerciseLaunchComponent, Lti13DynamicRegistrationComponent], - exports: [Lti13ExerciseLaunchComponent, Lti13DynamicRegistrationComponent], + imports: [RouterModule.forChild(LTI_LAUNCH_ROUTES), ArtemisCoreModule, ArtemisSharedModule, FormsModule, ArtemisSharedComponentModule], + declarations: [Lti13ExerciseLaunchComponent, Lti13DynamicRegistrationComponent, Lti13DeepLinkingComponent, Lti13SelectContentComponent], + exports: [Lti13ExerciseLaunchComponent, Lti13DynamicRegistrationComponent, Lti13DeepLinkingComponent, Lti13SelectContentComponent], }) export class ArtemisLtiModule {} diff --git a/src/main/webapp/app/lti/lti.route.ts b/src/main/webapp/app/lti/lti.route.ts index a4004dbe7903..54614ac6b8da 100644 --- a/src/main/webapp/app/lti/lti.route.ts +++ b/src/main/webapp/app/lti/lti.route.ts @@ -1,6 +1,8 @@ import { Routes } from '@angular/router'; import { Lti13ExerciseLaunchComponent } from 'app/lti/lti13-exercise-launch.component'; import { Lti13DynamicRegistrationComponent } from 'app/lti/lti13-dynamic-registration.component'; +import { Lti13DeepLinkingComponent } from 'app/lti/lti13-deep-linking.component'; +import { Lti13SelectContentComponent } from 'app/lti/lti13-select-content.component'; export const ltiLaunchRoutes: Routes = [ { @@ -17,6 +19,20 @@ export const ltiLaunchRoutes: Routes = [ pageTitle: 'artemisApp.lti13.dynamicRegistration.title', }, }, + { + path: 'select-content', + component: Lti13SelectContentComponent, + data: { + pageTitle: 'artemisApp.lti13.deepLinking.title', + }, + }, + { + path: 'deep-linking/:courseId', + component: Lti13DeepLinkingComponent, + data: { + pageTitle: 'artemisApp.lti13.deepLinking.title', + }, + }, ]; const LTI_LAUNCH_ROUTES = [...ltiLaunchRoutes]; diff --git a/src/main/webapp/app/lti/lti13-deep-linking.component.html b/src/main/webapp/app/lti/lti13-deep-linking.component.html new file mode 100644 index 000000000000..8f6c575370bb --- /dev/null +++ b/src/main/webapp/app/lti/lti13-deep-linking.component.html @@ -0,0 +1,76 @@ +
+ + + +
+

Error during deep linking

diff --git a/src/main/webapp/app/lti/lti13-deep-linking.component.ts b/src/main/webapp/app/lti/lti13-deep-linking.component.ts new file mode 100644 index 000000000000..9976b005d1f8 --- /dev/null +++ b/src/main/webapp/app/lti/lti13-deep-linking.component.ts @@ -0,0 +1,149 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { Exercise } from 'app/entities/exercise.model'; +import { faExclamationTriangle, faSort, faWrench } from '@fortawesome/free-solid-svg-icons'; +import { SortService } from 'app/shared/service/sort.service'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { AccountService } from 'app/core/auth/account.service'; +import { Course } from 'app/entities/course.model'; +import { AlertService } from 'app/core/util/alert.service'; +import { onError } from 'app/shared/util/global.utils'; +import { SessionStorageService } from 'ngx-webstorage'; + +@Component({ + selector: 'jhi-deep-linking', + templateUrl: './lti13-deep-linking.component.html', +}) +export class Lti13DeepLinkingComponent implements OnInit { + courseId: number; + exercises: Exercise[]; + selectedExercise?: Exercise; + course: Course; + + predicate = 'type'; + reverse = false; + isLinking = true; + + // Icons + faSort = faSort; + faExclamationTriangle = faExclamationTriangle; + faWrench = faWrench; + constructor( + public route: ActivatedRoute, + private sortService: SortService, + private courseManagementService: CourseManagementService, + private http: HttpClient, + private accountService: AccountService, + private router: Router, + private alertService: AlertService, + private sessionStorageService: SessionStorageService, + ) {} + + /** + * Initializes the component. + * - Retrieves the course ID from the route parameters. + * - Fetches the user's identity. + * - Retrieves the course details and exercises. + * - Redirects unauthenticated users to the login page. + */ + ngOnInit() { + this.route.params.subscribe((params) => { + this.courseId = Number(params['courseId']); + + if (!this.courseId) { + this.isLinking = false; + return; + } + if (!this.isLinking) { + return; + } + + this.accountService.identity().then((user) => { + if (user) { + this.courseManagementService.findWithExercises(this.courseId).subscribe((findWithExercisesResult) => { + if (findWithExercisesResult?.body?.exercises) { + this.course = findWithExercisesResult.body; + this.exercises = findWithExercisesResult.body.exercises; + } + }); + } else { + this.redirectUserToLoginThenTargetLink(window.location.href); + } + }); + }); + } + + /** + * Redirects the user to the login page and sets up a listener for user login. + * After login, redirects the user back to the original link. + * + * @param currentLink The current URL to return to after login. + */ + redirectUserToLoginThenTargetLink(currentLink: string): void { + this.router.navigate(['/']).then(() => { + this.accountService.getAuthenticationState().subscribe((user) => { + if (user) { + window.location.replace(currentLink); + } + }); + }); + } + + /** + * Sorts the list of exercises based on the selected predicate and order. + */ + sortRows() { + this.sortService.sortByProperty(this.exercises, this.predicate, this.reverse); + } + + /** + * Toggles the selected exercise. + * + * @param exercise The exercise to toggle. + */ + toggleExercise(exercise: Exercise) { + this.selectedExercise = exercise; + } + + /** + * Checks if the given exercise is currently selected. + * + * @param exercise The exercise to check. + * @returns True if the exercise is selected, false otherwise. + */ + isExerciseSelected(exercise: Exercise) { + return this.selectedExercise === exercise; + } + + /** + * Sends a deep link request for the selected exercise. + * If an exercise is selected, it sends a POST request to initiate deep linking. + */ + sendDeepLinkRequest() { + if (this.selectedExercise) { + const ltiIdToken = this.sessionStorageService.retrieve('ltiIdToken') ?? ''; + const clientRegistrationId = this.sessionStorageService.retrieve('clientRegistrationId') ?? ''; + + const httpParams = new HttpParams().set('exerciseId', this.selectedExercise.id!).set('ltiIdToken', ltiIdToken!).set('clientRegistrationId', clientRegistrationId!); + + this.http.post(`api/lti13/deep-linking/${this.courseId}`, null, { observe: 'response', params: httpParams }).subscribe({ + next: (response) => { + if (response.status === 200) { + if (response.body) { + const targetLink = response.body['targetLinkUri']; + window.location.replace(targetLink); + } + } else { + this.isLinking = false; + this.alertService.error('artemisApp.lti13.deepLinking.unknownError'); + } + }, + error: (error) => { + this.isLinking = false; + onError(this.alertService, error); + }, + }); + } + } +} diff --git a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts index e044f24c5098..de457e61a445 100644 --- a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts +++ b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts @@ -2,6 +2,8 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { AccountService } from 'app/core/auth/account.service'; +import { captureException } from '@sentry/angular-ivy'; +import { SessionStorageService } from 'ngx-webstorage'; @Component({ selector: 'jhi-lti-exercise-launch', @@ -15,6 +17,7 @@ export class Lti13ExerciseLaunchComponent implements OnInit { private http: HttpClient, private accountService: AccountService, private router: Router, + private sessionStorageService: SessionStorageService, ) { this.isLaunching = true; } @@ -80,9 +83,17 @@ export class Lti13ExerciseLaunchComponent implements OnInit { }); } - redirectUserToTargetLink(error: any): void { + redirectUserToTargetLink(data: any): void { + // const ltiIdToken = error.headers.get('ltiIdToken'); + // const clientRegistrationId = error.headers.get('clientRegistrationId'); + + const ltiIdToken = data.error['ltiIdToken']; + const clientRegistrationId = data.error['clientRegistrationId']; + + this.storeLtiSessionData(ltiIdToken, clientRegistrationId); + // Redirect to target link since the user is already logged in - window.location.replace(error.headers.get('TargetLinkUri').toString()); + window.location.replace(data.error['targetLinkUri'].toString()); } redirectUserToLoginThenTargetLink(error: any): void { @@ -99,7 +110,11 @@ export class Lti13ExerciseLaunchComponent implements OnInit { handleLtiLaunchSuccess(data: NonNullable): void { const targetLinkUri = data['targetLinkUri']; + const ltiIdToken = data['ltiIdToken']; + const clientRegistrationId = data['clientRegistrationId']; + window.sessionStorage.removeItem('state'); + this.storeLtiSessionData(ltiIdToken, clientRegistrationId); if (targetLinkUri) { window.location.replace(targetLinkUri); @@ -113,4 +128,23 @@ export class Lti13ExerciseLaunchComponent implements OnInit { window.sessionStorage.removeItem('state'); this.isLaunching = false; } + + storeLtiSessionData(ltiIdToken: string, clientRegistrationId: string): void { + if (!ltiIdToken) { + captureException(new Error('LTI ID token required to store session data.')); + return; + } + + if (!clientRegistrationId) { + captureException(new Error('LTI client registration ID required to store session data.')); + return; + } + + try { + this.sessionStorageService.store('ltiIdToken', ltiIdToken); + this.sessionStorageService.store('clientRegistrationId', clientRegistrationId); + } catch (error) { + console.error('Failed to store session data:', error); + } + } } diff --git a/src/main/webapp/app/lti/lti13-select-content.component.html b/src/main/webapp/app/lti/lti13-select-content.component.html new file mode 100644 index 000000000000..fc2cbb6cca6a --- /dev/null +++ b/src/main/webapp/app/lti/lti13-select-content.component.html @@ -0,0 +1,8 @@ +

Linking...

+
+
+ + +
+
+

Error during deep linking

diff --git a/src/main/webapp/app/lti/lti13-select-content.component.ts b/src/main/webapp/app/lti/lti13-select-content.component.ts new file mode 100644 index 000000000000..f4afb5cca6b8 --- /dev/null +++ b/src/main/webapp/app/lti/lti13-select-content.component.ts @@ -0,0 +1,77 @@ +import { Component, ElementRef, NgZone, OnInit, SecurityContext, ViewChild } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { DomSanitizer } from '@angular/platform-browser'; + +/** + * Component responsible for sending deep linking content. + * It reads the necessary parameters from the route, sanitizes return URL, + * and automatically submits a form with the relevant data. + * According to LTI documentation auto submit form must be used. + */ +@Component({ + selector: 'jhi-select-exercise', + templateUrl: './lti13-select-content.component.html', +}) +export class Lti13SelectContentComponent implements OnInit { + jwt: string; + id: string; + actionLink: string; + isLinking = true; + + @ViewChild('deepLinkingForm', { static: false }) + deepLinkingForm?: ElementRef; + + constructor( + private route: ActivatedRoute, + private sanitizer: DomSanitizer, + private zone: NgZone, + ) {} + + /** + * Initializes the component. + * - Retrieves query parameters from the route snapshot. + * - Sets the action link for the form. + * - Automatically submits the form. + */ + ngOnInit(): void { + this.route.params.subscribe(() => { + this.updateFormValues(); + + // postpone auto-submit until after view updates are completed + // if not jwt and id is not submitted correctly + if (this.jwt && this.id) { + this.zone.runOutsideAngular(() => { + setTimeout(() => this.autoSubmitForm()); + }); + } + }); + } + + /** + * Updates the form values with query parameters + * - Retrieves query parameters from the route snapshot. + */ + updateFormValues(): void { + const deepLinkUri = this.route.snapshot.queryParamMap.get('deepLinkUri') ?? ''; + this.actionLink = this.sanitizer.sanitize(SecurityContext.URL, deepLinkUri) || ''; + this.jwt = this.route.snapshot.queryParamMap.get('jwt') ?? ''; + this.id = this.route.snapshot.queryParamMap.get('id') ?? ''; + if (this.actionLink === '' || this.jwt === '' || this.id === '') { + this.isLinking = false; + return; + } + } + + /** + * Automatically submits the form. + * - Sets the action link for the form. + * - Submits the form. + */ + autoSubmitForm(): void { + const form = this.deepLinkingForm?.nativeElement; + if (form) { + form.action = this.actionLink; + form.submit(); + } + } +} diff --git a/src/main/webapp/i18n/de/lti.json b/src/main/webapp/i18n/de/lti.json index e52608b857d7..47f067d8261a 100644 --- a/src/main/webapp/i18n/de/lti.json +++ b/src/main/webapp/i18n/de/lti.json @@ -76,7 +76,17 @@ "registeredSuccessfully": "Kurs erfolgreich registriert", "registerFailed": "Fehler bei der dynamischen Registrierung" }, - "missingConfigurationWarning": "Fehlende Werte in der LTI1.3-Konfiguration. Starts werden nicht funktionieren." + "deepLinking": { + "title": "Deep Linking", + "linking": "Verlinkung", + "linkedSuccessfully": "Verknüpfung der Übungen erfolgreich", + "linkedFailed": "Fehler beim Deep-Linking", + "link": "Verlinken", + "unknownError": "Aufgrund eines unbekannten Fehlers nicht erfolgreich. Bitte kontaktiere einen Admin!" + }, + "missingConfigurationWarning": "Fehlende Werte in der LTI1.3-Konfiguration. Starts werden nicht funktionieren.", + "selectContentFromCourse": "Wähle Inhalte aus dem Kurs {{ title }} aus", + "selectContentTooltip": "Wähle eine Übung aus und klicke dann auf die Schaltfläche Importieren, um sie in die Plattform zu integrieren." } } } diff --git a/src/main/webapp/i18n/en/lti.json b/src/main/webapp/i18n/en/lti.json index 502487204c38..0bf4d1e24a94 100644 --- a/src/main/webapp/i18n/en/lti.json +++ b/src/main/webapp/i18n/en/lti.json @@ -76,7 +76,17 @@ "registeredSuccessfully": "Registered course successfully", "registerFailed": "Error during dynamic registration" }, - "missingConfigurationWarning": "Missing values in the LTI1.3 configuration. Launches will not work." + "deepLinking": { + "title": "Deep Linking", + "linking": "Linking", + "linkedSuccessfully": "Linked exercises successfully", + "linkedFailed": "Error during deep linking", + "link": "Link", + "unknownError": "Unsuccessful due to an unknown error. Please contact an admin!" + }, + "missingConfigurationWarning": "Missing values in the LTI1.3 configuration. Launches will not work.", + "selectContentFromCourse": "Select content from course {{ title }}", + "selectContentTooltip": "Simply select your preferred exercise and then click the Import button to integrate it into the platform." } } } diff --git a/src/test/java/de/tum/in/www1/artemis/AbstractSpringIntegrationBambooBitbucketJiraTest.java b/src/test/java/de/tum/in/www1/artemis/AbstractSpringIntegrationBambooBitbucketJiraTest.java index 9cab1f27617f..051a80decebf 100644 --- a/src/test/java/de/tum/in/www1/artemis/AbstractSpringIntegrationBambooBitbucketJiraTest.java +++ b/src/test/java/de/tum/in/www1/artemis/AbstractSpringIntegrationBambooBitbucketJiraTest.java @@ -44,6 +44,7 @@ import de.tum.in.www1.artemis.domain.participation.ProgrammingExerciseParticipation; import de.tum.in.www1.artemis.domain.participation.ProgrammingExerciseStudentParticipation; import de.tum.in.www1.artemis.domain.participation.StudentParticipation; +import de.tum.in.www1.artemis.security.OAuth2JWKSService; import de.tum.in.www1.artemis.service.TimeService; import de.tum.in.www1.artemis.service.connectors.bamboo.BambooResultService; import de.tum.in.www1.artemis.service.connectors.bamboo.BambooService; @@ -97,6 +98,9 @@ public abstract class AbstractSpringIntegrationBambooBitbucketJiraTest extends A @SpyBean protected BambooServer bambooServer; + @SpyBean + protected OAuth2JWKSService oAuth2JWKSService; + @Autowired protected BambooRequestMockProvider bambooRequestMockProvider; @@ -111,7 +115,7 @@ public abstract class AbstractSpringIntegrationBambooBitbucketJiraTest extends A @AfterEach protected void resetSpyBeans() { - Mockito.reset(ldapUserService, continuousIntegrationUpdateService, continuousIntegrationService, versionControlService, bambooServer, textBlockService); + Mockito.reset(ldapUserService, continuousIntegrationUpdateService, continuousIntegrationService, versionControlService, bambooServer, textBlockService, oAuth2JWKSService); super.resetSpyBeans(); } diff --git a/src/test/java/de/tum/in/www1/artemis/LtiDeepLinkingIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/LtiDeepLinkingIntegrationTest.java new file mode 100644 index 000000000000..532db883caae --- /dev/null +++ b/src/test/java/de/tum/in/www1/artemis/LtiDeepLinkingIntegrationTest.java @@ -0,0 +1,206 @@ +package de.tum.in.www1.artemis; + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.crypto.SecretKey; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.util.LinkedMultiValueMap; + +import com.nimbusds.jose.jwk.JWK; + +import de.tum.in.www1.artemis.course.CourseUtilService; +import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.lti.Claims; +import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseUtilService; +import de.tum.in.www1.artemis.repository.CourseRepository; +import de.tum.in.www1.artemis.repository.UserRepository; +import de.tum.in.www1.artemis.user.UserUtilService; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; + +class LtiDeepLinkingIntegrationTest extends AbstractSpringIntegrationBambooBitbucketJiraTest { + + private static final String TEST_PREFIX = "ltideeplinkingintegrationtest"; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserUtilService userUtilService; + + @Autowired + private ProgrammingExerciseUtilService programmingExerciseUtilService; + + @Autowired + private CourseUtilService courseUtilService; + + private Course course; + + @Autowired + private CourseRepository courseRepository; + + @BeforeEach + void init() { + userUtilService.addUsers(TEST_PREFIX, 1, 1, 0, 1); + var user = userRepository.findUserWithGroupsAndAuthoritiesByLogin(TEST_PREFIX + "student1").orElseThrow(); + user.setInternal(false); + userRepository.save(user); + + course = programmingExerciseUtilService.addCourseWithOneProgrammingExercise(); + course.setOnlineCourse(true); + courseUtilService.addOnlineCourseConfigurationToCourse(course); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void deepLinkingFailsAsStudent() throws Exception { + var params = getDeepLinkingRequestParams(); + + request.postWithoutResponseBody("/api/lti13/deep-linking/" + course.getId(), HttpStatus.FORBIDDEN, params); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void deepLinkingFailsWithoutExerciseId() throws Exception { + request.postWithoutResponseBody("/api/lti13/deep-linking/" + course.getId(), HttpStatus.BAD_REQUEST, new LinkedMultiValueMap<>()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void deepLinkingFailsForNonOnlineCourse() throws Exception { + course.setOnlineCourse(false); + course.setOnlineCourseConfiguration(null); + courseRepository.save(course); + + var params = getDeepLinkingRequestParams(); + + request.postWithoutResponseBody("/api/lti13/deep-linking/" + course.getId(), HttpStatus.BAD_REQUEST, params); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void deepLinkingAsInstructor() throws Exception { + String jwkJsonString = "{\"kty\":\"RSA\",\"d\":\"base64-encoded-value\",\"e\":\"AQAB\",\"use\":\"sig\",\"kid\":\"123456\",\"alg\":\"RS256\",\"n\":\"base64-encoded-value\"}"; + when(this.oAuth2JWKSService.getJWK(any())).thenReturn(JWK.parse(jwkJsonString)); + var params = getDeepLinkingRequestParams(); + + request.postWithoutResponseBody("/api/lti13/deep-linking/" + course.getId(), HttpStatus.BAD_REQUEST, params); + } + + private LinkedMultiValueMap getDeepLinkingRequestParams() { + var params = new LinkedMultiValueMap(); + params.add("exerciseId", "1"); + params.add("ltiIdToken", createJwtForTest()); + params.add("clientRegistrationId", "registration-id"); + return params; + } + + private String createJwtForTest() { + SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256); + + Map claims = prepareTokenClaims(); + + return Jwts.builder().setClaims(claims).signWith(key, SignatureAlgorithm.HS256).compact(); + } + + private Map prepareTokenClaims() { + Map claims = new HashMap<>(); + + addUserClaims(claims); + addLTISpecificClaims(claims); + addContextClaim(claims); + addToolPlatformClaim(claims); + addLaunchPresentationClaim(claims); + addCustomClaim(claims); + addDeepLinkingSettingsClaim(claims); + + return claims; + } + + private void addUserClaims(Map claims) { + claims.put("iss", "https://platform.example.org"); + claims.put("sub", "a6d5c443-1f51-4783-ba1a-7686ffe3b54a"); + claims.put("aud", List.of("962fa4d8-bcbf-49a0-94b2-2de05ad274af")); + claims.put("exp", new Date(System.currentTimeMillis() + 3600_000)); // Token is valid for 1 hour + claims.put("iat", new Date(System.currentTimeMillis())); + claims.put("azp", "962fa4d8-bcbf-49a0-94b2-2de05ad274af"); + claims.put("nonce", "fc5fdc6d-5dd6-47f4-b2c9-5d1216e9b771"); + claims.put("name", "Ms Jane Marie Doe"); + claims.put("given_name", "Jane"); + claims.put("family_name", "Doe"); + claims.put("middle_name", "Marie"); + claims.put("picture", "https://example.org/jane.jpg"); + claims.put("email", "jane@example.org"); + claims.put("locale", "en-US"); + } + + private void addLTISpecificClaims(Map claims) { + claims.put(Claims.LTI_DEPLOYMENT_ID, "07940580-b309-415e-a37c-914d387c1150"); + claims.put(Claims.MESSAGE_TYPE, "LtiDeepLinkingRequest"); + claims.put(Claims.LTI_VERSION, "1.3.0"); + claims.put(Claims.ROLES, + Arrays.asList("http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor", "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Faculty")); + + } + + private void addContextClaim(Map claims) { + Map contextClaim = new HashMap<>(); + contextClaim.put("id", "c1d887f0-a1a3-4bca-ae25-c375edcc131a"); + contextClaim.put("label", "ECON 101"); + contextClaim.put("title", "Economics as a Social Science"); + contextClaim.put("type", List.of("CourseOffering")); + claims.put(Claims.CONTEXT, contextClaim); + } + + private void addToolPlatformClaim(Map claims) { + Map toolPlatformClaim = new HashMap<>(); + toolPlatformClaim.put("contact_email", "support@example.org"); + toolPlatformClaim.put("description", "An Example Tool Platform"); + toolPlatformClaim.put("name", "Example Tool Platform"); + toolPlatformClaim.put("url", "https://example.org"); + toolPlatformClaim.put("product_family_code", "example.org"); + toolPlatformClaim.put("version", "1.0"); + claims.put(Claims.PLATFORM_INSTANCE, toolPlatformClaim); + } + + private void addLaunchPresentationClaim(Map claims) { + Map launchPresentationClaim = new HashMap<>(); + launchPresentationClaim.put("document_target", "iframe"); + launchPresentationClaim.put("height", 320); + launchPresentationClaim.put("width", 240); + claims.put(Claims.LAUNCH_PRESENTATION, launchPresentationClaim); + } + + private void addCustomClaim(Map claims) { + Map customClaim = new HashMap<>(); + customClaim.put("myCustom", "123"); + claims.put(Claims.CUSTOM, customClaim); + } + + private void addDeepLinkingSettingsClaim(Map claims) { + Map deepLinkingSettingsClaim = new HashMap<>(); + deepLinkingSettingsClaim.put("deep_link_return_url", "https://platform.example/deep_links"); + deepLinkingSettingsClaim.put("accept_types", Arrays.asList("link", "file", "html", "ltiResourceLink", "image")); + deepLinkingSettingsClaim.put("accept_media_types", "image/*,text/html"); + deepLinkingSettingsClaim.put("accept_presentation_document_targets", Arrays.asList("iframe", "window", "embed")); + deepLinkingSettingsClaim.put("accept_multiple", true); + deepLinkingSettingsClaim.put("auto_create", true); + deepLinkingSettingsClaim.put("title", "This is the default title"); + deepLinkingSettingsClaim.put("text", "This is the default text"); + deepLinkingSettingsClaim.put("data", "csrftoken:c7fbba78-7b75-46e3-9201-11e6d5f36f53"); + claims.put(Claims.DEEP_LINKING_SETTINGS, deepLinkingSettingsClaim); + } +} diff --git a/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java index d15b84a2b3c3..a98cd1800f9e 100644 --- a/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java @@ -354,4 +354,5 @@ private void assertParametersNewStudent(MultiValueMap parameters assertThat(parameters.getFirst("initialize")).isNotNull(); assertThat(parameters.getFirst("ltiSuccessLoginRequired")).isNull(); } + } diff --git a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java index d74c2b1991fd..6a5d43229ba1 100644 --- a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java @@ -3,10 +3,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentCaptor.*; import static org.mockito.Mockito.*; import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.Optional; import javax.servlet.http.HttpServletResponse; @@ -105,46 +107,26 @@ void tearDown() throws Exception { @Test void performLaunch_exerciseFound() { - long exerciseId = 134; - Exercise exercise = new TextExercise(); - exercise.setId(exerciseId); - - long courseId = 12; - Course course = new Course(); - course.setId(courseId); - course.setOnlineCourseConfiguration(new OnlineCourseConfiguration()); - exercise.setCourse(course); - doReturn(Optional.of(exercise)).when(exerciseRepository).findById(exerciseId); - doReturn(course).when(courseRepository).findByIdWithEagerOnlineCourseConfigurationElseThrow(courseId); + MockExercise result = getMockExercise(true); when(oidcIdToken.getClaim("sub")).thenReturn("1"); - when(oidcIdToken.getClaim("iss")).thenReturn("http://otherDomain.com"); + when(oidcIdToken.getClaim("iss")).thenReturn("https://otherDomain.com"); when(oidcIdToken.getClaim(Claims.LTI_DEPLOYMENT_ID)).thenReturn("1"); JsonObject jsonObject = new JsonObject(); jsonObject.addProperty("id", "resourceLinkUrl"); when(oidcIdToken.getClaim(Claims.RESOURCE_LINK)).thenReturn(jsonObject); - prepareForPerformLaunch(courseId, exerciseId); + prepareForPerformLaunch(result.courseId(), result.exerciseId()); lti13Service.performLaunch(oidcIdToken, clientRegistrationId); - verify(launchRepository).findByIssAndSubAndDeploymentIdAndResourceLinkId("http://otherDomain.com", "1", "1", "resourceLinkUrl"); + verify(launchRepository).findByIssAndSubAndDeploymentIdAndResourceLinkId("https://otherDomain.com", "1", "1", "resourceLinkUrl"); verify(launchRepository).save(any()); } @Test void performLaunch_invalidToken() { - long exerciseId = 134; - Exercise exercise = new TextExercise(); - exercise.setId(exerciseId); - - long courseId = 12; - Course course = new Course(); - course.setId(courseId); - course.setOnlineCourseConfiguration(onlineCourseConfiguration); - exercise.setCourse(course); - doReturn(Optional.of(exercise)).when(exerciseRepository).findById(exerciseId); - doReturn(course).when(courseRepository).findByIdWithEagerOnlineCourseConfigurationElseThrow(courseId); - prepareForPerformLaunch(courseId, exerciseId); + MockExercise exercise = this.getMockExercise(true); + prepareForPerformLaunch(exercise.courseId, exercise.exerciseId); assertThatIllegalArgumentException().isThrownBy(() -> lti13Service.performLaunch(oidcIdToken, clientRegistrationId)); @@ -212,19 +194,9 @@ void performLaunch_courseNotFound() { @Test void performLaunch_notOnlineCourse() { - long exerciseId = 134; - Exercise exercise = new TextExercise(); - exercise.setId(exerciseId); - - long courseId = 12; - Course course = new Course(); - course.setId(courseId); - exercise.setCourse(course); - doReturn(Optional.of(exercise)).when(exerciseRepository).findById(exerciseId); - doReturn(course).when(courseRepository).findByIdWithEagerOnlineCourseConfigurationElseThrow(courseId); - + MockExercise exercise = this.getMockExercise(false); OidcIdToken oidcIdToken = mock(OidcIdToken.class); - doReturn("https://some-artemis-domain.org/courses/" + courseId + "/exercises/" + exerciseId).when(oidcIdToken).getClaim(Claims.TARGET_LINK_URI); + doReturn("https://some-artemis-domain.org/courses/" + exercise.courseId + "/exercises/" + exercise.exerciseId).when(oidcIdToken).getClaim(Claims.TARGET_LINK_URI); assertThatExceptionOfType(BadRequestAlertException.class).isThrownBy(() -> lti13Service.performLaunch(oidcIdToken, clientRegistrationId)); } @@ -443,18 +415,21 @@ void onNewResult() { lti13Service.onNewResult(participation); - ArgumentCaptor urlCapture = ArgumentCaptor.forClass(String.class); - ArgumentCaptor> httpEntityCapture = ArgumentCaptor.forClass(HttpEntity.class); + ArgumentCaptor urlCapture = forClass(String.class); + var httpEntityCapture = forClass(HttpEntity.class); verify(restTemplate).postForEntity(urlCapture.capture(), httpEntityCapture.capture(), any()); - HttpEntity httpEntity = httpEntityCapture.getValue(); + HttpEntity capturedHttpEntity = httpEntityCapture.getValue(); + assertThat(capturedHttpEntity.getBody()).isInstanceOf(String.class); + + HttpEntity httpEntity = new HttpEntity<>((String) capturedHttpEntity.getBody(), capturedHttpEntity.getHeaders()); List authHeaders = httpEntity.getHeaders().get("Authorization"); assertThat(authHeaders).as("Score publish request must contain an Authorization header").isNotNull(); assertThat(authHeaders).as("Score publish request must contain the corresponding Authorization Bearer token").contains("Bearer " + accessToken); - JsonObject body = JsonParser.parseString(httpEntity.getBody()).getAsJsonObject(); + JsonObject body = JsonParser.parseString(Objects.requireNonNull(httpEntity.getBody())).getAsJsonObject(); assertThat(body.get("userId").getAsString()).as("Invalid parameter in score publish request: userId").isEqualTo(launch.getSub()); assertThat(body.get("timestamp").getAsString()).as("Parameter missing in score publish request: timestamp").isNotNull(); assertThat(body.get("activityProgress").getAsString()).as("Parameter missing in score publish request: activityProgress").isNotNull(); @@ -465,6 +440,55 @@ void onNewResult() { assertThat(body.get("scoreMaximum").getAsDouble()).as("Invalid parameter in score publish request: scoreMaximum").isEqualTo(100d); assertThat(launch.getScoreLineItemUrl() + "/scores").as("Score publish request was sent to a wrong URI").isEqualTo(urlCapture.getValue()); + + } + + @Test + void startDeepLinkingCourseFound() { + MockExercise mockExercise = this.getMockExercise(true); + + when(oidcIdToken.getClaim(Claims.LTI_DEPLOYMENT_ID)).thenReturn("1"); + when(oidcIdToken.getClaim(Claims.MESSAGE_TYPE)).thenReturn("LtiDeepLinkingRequest"); + when(oidcIdToken.getClaim(Claims.TARGET_LINK_URI)).thenReturn("https://some-artemis-domain.org/lti/deep-linking/" + mockExercise.courseId); + + when(oidcIdToken.getEmail()).thenReturn("testuser@email.com"); + + Optional user = Optional.of(new User()); + doReturn(user).when(userRepository).findOneWithGroupsAndAuthoritiesByLogin(any()); + doNothing().when(ltiService).authenticateLtiUser(any(), any(), any(), any(), anyBoolean()); + doNothing().when(ltiService).onSuccessfulLtiAuthentication(any(), any()); + + lti13Service.startDeepLinking(oidcIdToken); + } + + @Test + void startDeepLinkingNotOnlineCourse() { + MockExercise exercise = this.getMockExercise(false); + + OidcIdToken oidcIdToken = mock(OidcIdToken.class); + when(oidcIdToken.getClaim(Claims.TARGET_LINK_URI)).thenReturn("https://some-artemis-domain.org/lti/deep-linking/" + exercise.courseId); + + assertThatExceptionOfType(BadRequestAlertException.class).isThrownBy(() -> lti13Service.startDeepLinking(oidcIdToken)); + + } + + @Test + void startDeepLinkingCourseNotFound() { + when(oidcIdToken.getClaim(Claims.TARGET_LINK_URI)).thenReturn("https://some-artemis-domain.org/lti/deep-linking/100000"); + assertThatExceptionOfType(BadRequestAlertException.class).isThrownBy(() -> lti13Service.startDeepLinking(oidcIdToken)); + } + + @Test + void startDeepLinkingInvalidPath() { + when(oidcIdToken.getClaim(Claims.TARGET_LINK_URI)).thenReturn("https://some-artemis-domain.org/with/invalid/path/to/deeplinking/11"); + assertThatExceptionOfType(BadRequestAlertException.class).isThrownBy(() -> lti13Service.startDeepLinking(oidcIdToken)); + } + + @Test + void startDeepLinkingMalformedUrl() { + doReturn("path").when(oidcIdToken).getClaim(Claims.TARGET_LINK_URI); + + assertThatExceptionOfType(BadRequestAlertException.class).isThrownBy(() -> lti13Service.startDeepLinking(oidcIdToken)); } private State getValidStateForNewResult(Result result) { @@ -503,6 +527,26 @@ private record State(LtiResourceLaunch ltiResourceLaunch, Exercise exercise, Use ClientRegistration clientRegistration) { } + private MockExercise getMockExercise(boolean isOnlineCourse) { + long exerciseId = 134; + Exercise exercise = new TextExercise(); + exercise.setId(exerciseId); + + long courseId = 12; + Course course = new Course(); + course.setId(courseId); + if (isOnlineCourse) { + course.setOnlineCourseConfiguration(new OnlineCourseConfiguration()); + } + exercise.setCourse(course); + doReturn(Optional.of(exercise)).when(exerciseRepository).findById(exerciseId); + doReturn(course).when(courseRepository).findByIdWithEagerOnlineCourseConfigurationElseThrow(courseId); + return new MockExercise(exerciseId, courseId); + } + + private record MockExercise(long exerciseId, long courseId) { + } + private void prepareForPerformLaunch(long courseId, long exerciseId) { when(oidcIdToken.getEmail()).thenReturn("testuser@email.com"); when(oidcIdToken.getClaim(Claims.TARGET_LINK_URI)).thenReturn("https://some-artemis-domain.org/courses/" + courseId + "/exercises/" + exerciseId); diff --git a/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java b/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java index 7dd90e9538d3..b58c203ffbd6 100644 --- a/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java +++ b/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java @@ -3,11 +3,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; +import java.io.IOException; import java.io.PrintWriter; +import java.io.StringWriter; import java.util.HashMap; import java.util.Map; import javax.servlet.FilterChain; +import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -29,8 +32,10 @@ import org.springframework.security.oauth2.core.oidc.user.OidcUser; import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import de.tum.in.www1.artemis.config.lti.CustomLti13Configurer; +import de.tum.in.www1.artemis.exception.LtiEmailAlreadyInUseException; import de.tum.in.www1.artemis.security.lti.Lti13LaunchFilter; import de.tum.in.www1.artemis.service.connectors.lti.Lti13Service; import uk.ac.ox.ctl.lti13.lti.Claims; @@ -115,26 +120,39 @@ private void initValidIdToken() { idTokenClaims.put(Claims.TARGET_LINK_URI, targetLinkUri); } + private void initValidTokenForDeepLinking() { + idTokenClaims.put("iss", "https://some.lms.org"); + idTokenClaims.put("aud", "[962fa4d8-bcbf-49a0-94b2-2de05ad274af]"); + idTokenClaims.put("exp", "1510185728"); + idTokenClaims.put("iat", "1510185228"); + idTokenClaims.put("nonce", "fc5fdc6d-5dd6-47f4-b2c9-5d1216e9b771"); + idTokenClaims.put(Claims.LTI_DEPLOYMENT_ID, "some-deployment-id"); + + idTokenClaims.put(Claims.DEEP_LINKING_SETTINGS, "{ \"deep_link_return_url\": \"https://platform.example/deep_links\" }"); + idTokenClaims.put(Claims.TARGET_LINK_URI, "https://any-artemis-domain.org/lti/deep-linking/121"); + idTokenClaims.put(Claims.MESSAGE_TYPE, "LtiDeepLinkingRequest"); + } + @Test void authenticatedLogin() throws Exception { doReturn(true).when(authentication).isAuthenticated(); - doReturn(CustomLti13Configurer.LTI13_LOGIN_PATH).when(httpRequest).getServletPath(); - doReturn(oidcToken).when(defaultFilter).attemptAuthentication(any(), any()); - doReturn(responseWriter).when(httpResponse).getWriter(); - initValidIdToken(); - - launchFilter.doFilter(httpRequest, httpResponse, filterChain); - - verify(httpResponse, never()).setStatus(HttpStatus.UNAUTHORIZED.value()); - verify(httpResponse).setContentType("application/json"); - verify(httpResponse).setCharacterEncoding("UTF-8"); + JsonObject responseJsonBody = getMockJsonObject(false); verify(lti13Service).performLaunch(any(), any()); + verify(httpResponse, never()).setStatus(HttpStatus.UNAUTHORIZED.value()); + assertThat((responseJsonBody.get("targetLinkUri").getAsString())).as("Response body contains the expected targetLinkUri").contains(this.targetLinkUri); + verify(lti13Service).buildLtiResponse(any(), any()); + } - ArgumentCaptor argument = ArgumentCaptor.forClass(JsonObject.class); - verify(responseWriter).print(argument.capture()); - JsonObject responseJsonBody = argument.getValue(); + @Test + void authenticatedLoginForDeepLinking() throws Exception { + doReturn(true).when(authentication).isAuthenticated(); + JsonObject responseJsonBody = getMockJsonObject(true); + verify(lti13Service).startDeepLinking(any()); + verify(httpResponse, never()).setStatus(HttpStatus.UNAUTHORIZED.value()); + assertThat((responseJsonBody.get("targetLinkUri").toString())).as("Response body contains the expected targetLinkUri") + .contains("https://any-artemis-domain.org/lti/deep-linking/121"); verify(lti13Service).buildLtiResponse(any(), any()); - assertThat((responseJsonBody.get("targetLinkUri").getAsString())).as("Response body contains the expected targetLinkUri").contains(this.targetLinkUri); + } @Test @@ -147,6 +165,7 @@ void authenticatedLogin_oauth2AuthenticationException() throws Exception { verify(httpResponse).sendError(eq(HttpStatus.INTERNAL_SERVER_ERROR.value()), any()); verify(lti13Service, never()).performLaunch(any(), any()); + verify(lti13Service, never()).startDeepLinking(any()); } @Test @@ -159,6 +178,7 @@ void authenticatedLogin_noAuthenticationTokenReturned() throws Exception { verify(httpResponse).sendError(eq(HttpStatus.INTERNAL_SERVER_ERROR.value()), any()); verify(lti13Service, never()).performLaunch(any(), any()); + verify(lti13Service, never()).startDeepLinking(any()); } @Test @@ -171,4 +191,67 @@ void authenticatedLogin_serviceLaunchFailed() throws Exception { verify(httpResponse).sendError(eq(HttpStatus.INTERNAL_SERVER_ERROR.value()), any()); } + + @Test + void emailAddressAlreadyInUseServiceLaunchFailed() throws ServletException, IOException { + StringWriter stringWriter = new StringWriter(); + PrintWriter printWriter = new PrintWriter(stringWriter); + doReturn(printWriter).when(httpResponse).getWriter(); + + doReturn(false).when(authentication).isAuthenticated(); + doThrow(new LtiEmailAlreadyInUseException()).when(lti13Service).performLaunch(any(), any()); + + doReturn(CustomLti13Configurer.LTI13_LOGIN_PATH).when(httpRequest).getServletPath(); + doReturn(oidcToken).when(defaultFilter).attemptAuthentication(any(), any()); + + JsonObject responseJsonBody = getMockJsonObject(false); + + verify(httpResponse).setStatus(HttpStatus.UNAUTHORIZED.value()); + assertThat((responseJsonBody.get("targetLinkUri").toString())).as("Response body contains the expected targetLinkUri") + .contains("https://any-artemis-domain.org/course/123/exercise/1234"); + assertThat(responseJsonBody.get("ltiIdToken")).isNull(); + assertThat((responseJsonBody.get("clientRegistrationId").toString())).as("Response body contains the expected clientRegistrationId").contains("some-registration"); + } + + @Test + void emailAddressAlreadyInUseServiceDeepLinkingFailed() throws ServletException, IOException { + doReturn(false).when(authentication).isAuthenticated(); + doThrow(new LtiEmailAlreadyInUseException()).when(lti13Service).startDeepLinking(any()); + + doReturn(CustomLti13Configurer.LTI13_LOGIN_PATH).when(httpRequest).getServletPath(); + doReturn(oidcToken).when(defaultFilter).attemptAuthentication(any(), any()); + initValidTokenForDeepLinking(); + + JsonObject responseJsonBody = getMockJsonObject(true); + + verify(httpResponse).setStatus(HttpStatus.UNAUTHORIZED.value()); + assertThat((responseJsonBody.get("targetLinkUri").toString())).as("Response body contains the expected targetLinkUri") + .contains("https://any-artemis-domain.org/lti/deep-linking/121"); + assertThat(responseJsonBody.get("ltiIdToken")).isNull(); + assertThat((responseJsonBody.get("clientRegistrationId").toString())).as("Response body contains the expected clientRegistrationId").contains("some-registration"); + + } + + private JsonObject getMockJsonObject(boolean isDeepLinkingRequest) throws IOException, ServletException { + doReturn(CustomLti13Configurer.LTI13_LOGIN_PATH).when(httpRequest).getServletPath(); + doReturn(oidcToken).when(defaultFilter).attemptAuthentication(any(), any()); + doReturn(responseWriter).when(httpResponse).getWriter(); + if (isDeepLinkingRequest) { + initValidTokenForDeepLinking(); + } + else { + initValidIdToken(); + } + + launchFilter.doFilter(httpRequest, httpResponse, filterChain); + + verify(httpResponse).setContentType("application/json"); + + ArgumentCaptor argument = ArgumentCaptor.forClass(String.class); + verify(responseWriter).print(argument.capture()); + + String jsonResponseString = argument.getValue(); + + return JsonParser.parseString(jsonResponseString).getAsJsonObject(); + } } diff --git a/src/test/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetrieverTest.java b/src/test/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetrieverTest.java index 007e45f3fa50..e827adcdb1cc 100644 --- a/src/test/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetrieverTest.java +++ b/src/test/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetrieverTest.java @@ -10,6 +10,7 @@ import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.interfaces.RSAPublicKey; +import java.text.ParseException; import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -29,10 +30,13 @@ import org.springframework.web.client.RestTemplate; import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JOSEObjectType; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.KeyUse; import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; import de.tum.in.www1.artemis.domain.lti.Scopes; import de.tum.in.www1.artemis.security.OAuth2JWKSService; @@ -162,6 +166,30 @@ void getToken() throws NoSuchAlgorithmException { assertThat(token).isEqualTo("result"); } + @Test + void getJWTToken() throws NoSuchAlgorithmException, ParseException { + JWK jwk = generateKey(); + when(oAuth2JWKSService.getJWK(any())).thenReturn(jwk); + + Map claims = new HashMap<>(); + claims.put("customClaim1", "value1"); + claims.put("customClaim2", "value2"); + + String token = lti13TokenRetriever.createDeepLinkingJWT(clientRegistration.getRegistrationId(), claims); + + verify(oAuth2JWKSService).getJWK(clientRegistration.getRegistrationId()); + assertThat(token).isNotNull(); + + SignedJWT signedJWT = SignedJWT.parse(token); + JWTClaimsSet claimsSet = signedJWT.getJWTClaimsSet(); + + for (Map.Entry entry : claims.entrySet()) { + assertThat(entry.getValue()).isEqualTo(claimsSet.getClaim(entry.getKey())); + } + assertThat(JWSAlgorithm.RS256).isEqualTo(signedJWT.getHeader().getAlgorithm()); + assertThat(JOSEObjectType.JWT).isEqualTo(signedJWT.getHeader().getType()); + } + private JWK generateKey() throws NoSuchAlgorithmException { KeyPair clientKeyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); StringKeyGenerator kidGenerator = new Base64StringKeyGenerator(32); diff --git a/src/test/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingServiceTest.java b/src/test/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingServiceTest.java new file mode 100644 index 000000000000..91e51d0218d5 --- /dev/null +++ b/src/test/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingServiceTest.java @@ -0,0 +1,152 @@ +package de.tum.in.www1.artemis.service.connectors.lti; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.test.util.ReflectionTestUtils; + +import com.google.gson.JsonObject; + +import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.Exercise; +import de.tum.in.www1.artemis.domain.OnlineCourseConfiguration; +import de.tum.in.www1.artemis.domain.TextExercise; +import de.tum.in.www1.artemis.repository.ExerciseRepository; +import de.tum.in.www1.artemis.security.lti.Lti13TokenRetriever; +import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; +import uk.ac.ox.ctl.lti13.lti.Claims; + +class LtiDeepLinkingServiceTest { + + @Mock + private ExerciseRepository exerciseRepository; + + @Mock + private Lti13TokenRetriever tokenRetriever; + + private LtiDeepLinkingService ltiDeepLinkingService; + + private AutoCloseable closeable; + + private OidcIdToken oidcIdToken; + + @BeforeEach + void setUp() { + closeable = MockitoAnnotations.openMocks(this); + oidcIdToken = mock(OidcIdToken.class); + SecurityContextHolder.clearContext(); + ltiDeepLinkingService = new LtiDeepLinkingService(exerciseRepository, tokenRetriever); + ReflectionTestUtils.setField(ltiDeepLinkingService, "artemisServerUrl", "http://artemis.com"); + } + + @AfterEach + void tearDown() throws Exception { + if (closeable != null) { + closeable.close(); + } + } + + @Test + void testPerformDeepLinking() { + createMockOidcIdToken(); + when(tokenRetriever.createDeepLinkingJWT(anyString(), anyMap())).thenReturn("test_jwt"); + + long exerciseId = 3; + long courseId = 17; + Exercise exercise = createMockExercise(exerciseId, courseId); + when(exerciseRepository.findById(exerciseId)).thenReturn(Optional.of(exercise)); + + String deepLinkResponse = ltiDeepLinkingService.performDeepLinking(oidcIdToken, "test_registration_id", courseId, exerciseId); + + assertThat(deepLinkResponse).isNotNull(); + assertThat(deepLinkResponse).contains("test_jwt"); + } + + @Test + void testEmptyJwtBuildLtiDeepLinkResponse() { + createMockOidcIdToken(); + when(tokenRetriever.createDeepLinkingJWT(anyString(), anyMap())).thenReturn(null); + + long exerciseId = 3; + long courseId = 17; + Exercise exercise = createMockExercise(exerciseId, courseId); + when(exerciseRepository.findById(exerciseId)).thenReturn(Optional.of(exercise)); + + assertThatExceptionOfType(BadRequestAlertException.class) + .isThrownBy(() -> ltiDeepLinkingService.performDeepLinking(oidcIdToken, "test_registration_id", courseId, exerciseId)) + .withMessage("Deep linking response cannot be created") + .matches(exception -> "LTI".equals(exception.getEntityName()) && "deepLinkingResponseFailed".equals(exception.getErrorKey())); + } + + @Test + void testEmptyReturnUrlBuildLtiDeepLinkResponse() { + createMockOidcIdToken(); + when(tokenRetriever.createDeepLinkingJWT(anyString(), anyMap())).thenReturn("test_jwt"); + String jsonString = "{ \"deep_link_return_url\": \"\", " + "\"accept_types\": [\"link\", \"file\", \"html\", \"ltiResourceLink\", \"image\"], " + + "\"accept_media_types\": \"image/*,text/html\", " + "\"accept_presentation_document_targets\": [\"iframe\", \"window\", \"embed\"], " + + "\"accept_multiple\": true, " + "\"auto_create\": true, " + "\"title\": \"This is the default title\", " + "\"text\": \"This is the default text\", " + + "\"data\": \"csrftoken:c7fbba78-7b75-46e3-9201-11e6d5f36f53\"" + "}"; + when(oidcIdToken.getClaim(de.tum.in.www1.artemis.domain.lti.Claims.DEEP_LINKING_SETTINGS)).thenReturn(jsonString); + + long exerciseId = 3; + long courseId = 17; + Exercise exercise = createMockExercise(exerciseId, courseId); + when(exerciseRepository.findById(exerciseId)).thenReturn(Optional.of(exercise)); + + assertThatExceptionOfType(BadRequestAlertException.class) + .isThrownBy(() -> ltiDeepLinkingService.performDeepLinking(oidcIdToken, "test_registration_id", courseId, exerciseId)) + .withMessage("Cannot find platform return URL") + .matches(exception -> "LTI".equals(exception.getEntityName()) && "deepLinkReturnURLEmpty".equals(exception.getErrorKey())); + } + + @Test + void testEmptyDeploymentIdBuildLtiDeepLinkResponse() { + createMockOidcIdToken(); + when(tokenRetriever.createDeepLinkingJWT(anyString(), anyMap())).thenReturn("test_jwt"); + when(oidcIdToken.getClaim(de.tum.in.www1.artemis.domain.lti.Claims.LTI_DEPLOYMENT_ID)).thenReturn(null); + + long exerciseId = 3; + long courseId = 17; + Exercise exercise = createMockExercise(exerciseId, courseId); + when(exerciseRepository.findById(exerciseId)).thenReturn(Optional.of(exercise)); + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ltiDeepLinkingService.performDeepLinking(oidcIdToken, "test_registration_id", courseId, exerciseId)) + .withMessage("Missing claim: " + Claims.LTI_DEPLOYMENT_ID); + } + + private void createMockOidcIdToken() { + JsonObject mockSettings = new JsonObject(); + mockSettings.addProperty("deep_link_return_url", "test_return_url"); + when(oidcIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS)).thenReturn(mockSettings); + + when(oidcIdToken.getClaim("iss")).thenReturn("http://artemis.com"); + when(oidcIdToken.getClaim("aud")).thenReturn("http://moodle.com"); + when(oidcIdToken.getClaim("exp")).thenReturn("12345"); + when(oidcIdToken.getClaim("iat")).thenReturn("test"); + when(oidcIdToken.getClaim("nonce")).thenReturn("1234-34535-abcbcbd"); + when(oidcIdToken.getClaim(Claims.LTI_DEPLOYMENT_ID)).thenReturn("1"); + } + + private Exercise createMockExercise(long exerciseId, long courseId) { + Exercise exercise = new TextExercise(); + exercise.setTitle("test_title"); + exercise.setId(exerciseId); + + Course course = new Course(); + course.setId(courseId); + course.setOnlineCourseConfiguration(new OnlineCourseConfiguration()); + exercise.setCourse(course); + return exercise; + } +} diff --git a/src/test/javascript/spec/component/course/course-lti-configuration.component.spec.ts b/src/test/javascript/spec/component/course/course-lti-configuration.component.spec.ts index 83bc1e617eb7..c4da0e22adf2 100644 --- a/src/test/javascript/spec/component/course/course-lti-configuration.component.spec.ts +++ b/src/test/javascript/spec/component/course/course-lti-configuration.component.spec.ts @@ -117,7 +117,7 @@ describe('Course LTI Configuration Component', () => { expect(findWithExercisesStub).toHaveBeenCalledOnce(); expect(comp.getDynamicRegistrationUrl()).toBe(`${location.origin}/lti/dynamic-registration/${course.id}`); - expect(comp.getDeepLinkingUrl()).toBe(`${location.origin}/api/public/lti13/deep-linking/${course.id}`); + expect(comp.getDeepLinkingUrl()).toBe(`${location.origin}/lti/deep-linking/${course.id}`); expect(comp.getToolUrl()).toBe(`${location.origin}/courses/${course.id}`); expect(comp.getKeysetUrl()).toBe(`${location.origin}/.well-known/jwks.json`); expect(comp.getInitiateLoginUrl()).toBe(`${location.origin}/api/public/lti13/initiate-login/${course.onlineCourseConfiguration?.registrationId}`); diff --git a/src/test/javascript/spec/component/lti/lti13-deep-linking.component.spec.ts b/src/test/javascript/spec/component/lti/lti13-deep-linking.component.spec.ts new file mode 100644 index 000000000000..f968d7f777f5 --- /dev/null +++ b/src/test/javascript/spec/component/lti/lti13-deep-linking.component.spec.ts @@ -0,0 +1,180 @@ +import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { Lti13DeepLinkingComponent } from 'app/lti/lti13-deep-linking.component'; +import { ActivatedRoute, Router } from '@angular/router'; +import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { AccountService } from 'app/core/auth/account.service'; +import { SortService } from 'app/shared/service/sort.service'; +import { of, throwError } from 'rxjs'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { MockPipe, MockProvider } from 'ng-mocks'; +import { User } from 'app/core/user/user.model'; +import { Course } from 'app/entities/course.model'; +import { Exercise, ExerciseType } from 'app/entities/exercise.model'; +import { HelpIconComponent } from 'app/shared/components/help-icon.component'; +import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; +import { AlertService } from 'app/core/util/alert.service'; +import { MockSyncStorage } from '../../helpers/mocks/service/mock-sync-storage.service'; +import { SessionStorageService } from 'ngx-webstorage'; + +describe('Lti13DeepLinkingComponent', () => { + let component: Lti13DeepLinkingComponent; + let fixture: ComponentFixture; + let activatedRouteMock: any; + + const routerMock = { navigate: jest.fn() }; + const httpMock = { post: jest.fn() }; + const courseManagementServiceMock = { findWithExercises: jest.fn() }; + const accountServiceMock = { identity: jest.fn(), getAuthenticationState: jest.fn() }; + const sortServiceMock = { sortByProperty: jest.fn() }; + + const exercise1 = { id: 1, shortName: 'git', type: ExerciseType.PROGRAMMING } as Exercise; + const exercise2 = { id: 2, shortName: 'test', type: ExerciseType.PROGRAMMING } as Exercise; + const exercise3 = { id: 3, shortName: 'git', type: ExerciseType.MODELING } as Exercise; + const course = { id: 123, shortName: 'tutorial', exercises: [exercise2, exercise1, exercise3] } as Course; + + beforeEach(waitForAsync(() => { + activatedRouteMock = { params: of({ courseId: '123' }) }; + + TestBed.configureTestingModule({ + declarations: [Lti13DeepLinkingComponent, MockPipe(ArtemisTranslatePipe), HelpIconComponent, MockPipe(ArtemisDatePipe)], + providers: [ + { provide: ActivatedRoute, useValue: activatedRouteMock }, + { provide: Router, useValue: routerMock }, + { provide: HttpClient, useValue: httpMock }, + { provide: CourseManagementService, useValue: courseManagementServiceMock }, + { provide: AccountService, useValue: accountServiceMock }, + { provide: SortService, useValue: sortServiceMock }, + { provide: SessionStorageService, useClass: MockSyncStorage }, + MockProvider(AlertService), + ], + }).compileComponents(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(Lti13DeepLinkingComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should retrieve course details and exercises on init when user is authenticated', fakeAsync(() => { + const loggedInUserUser: User = { id: 3, login: 'lti_user', firstName: 'TestUser', lastName: 'Moodle' } as User; + accountServiceMock.identity.mockReturnValue(Promise.resolve(loggedInUserUser)); + courseManagementServiceMock.findWithExercises.mockReturnValue(of(new HttpResponse({ body: course }))); + + component.ngOnInit(); + tick(1000); + + expect(accountServiceMock.identity).toHaveBeenCalled(); + expect(courseManagementServiceMock.findWithExercises).toHaveBeenCalledWith(course.id); + expect(component.courseId).toBe(123); + expect(component.course).toEqual(course); + expect(component.exercises).toContainAllValues(course.exercises!); + })); + + it('should navigate on init when user is authenticated', fakeAsync(() => { + const redirectSpy = jest.spyOn(component, 'redirectUserToLoginThenTargetLink'); + accountServiceMock.identity.mockResolvedValue(undefined); + routerMock.navigate.mockReturnValue(Promise.resolve({})); + accountServiceMock.getAuthenticationState.mockReturnValue(of()); + + component.ngOnInit(); + tick(); + + expect(redirectSpy).toHaveBeenCalledWith(window.location.href); + expect(routerMock.navigate).toHaveBeenCalledWith(['/']); + expect(component.redirectUserToLoginThenTargetLink).toHaveBeenCalled(); + })); + + it('should not course details and exercises on init when courseId is empty', fakeAsync(() => { + activatedRouteMock.params = of({}); + fixture = TestBed.createComponent(Lti13DeepLinkingComponent); + component = fixture.componentInstance; + // Manually set the activatedRouteMock to component here + component.route = activatedRouteMock; + + component.ngOnInit(); + tick(1000); + + expect(component.isLinking).toBeFalse(); + expect(accountServiceMock.identity).not.toHaveBeenCalled(); + expect(courseManagementServiceMock.findWithExercises).not.toHaveBeenCalled(); + expect(component.courseId).toBeNaN(); + })); + + it('should not send deep link request when exercise is not selected', () => { + component.selectedExercise = undefined; + + component.sendDeepLinkRequest(); + + expect(httpMock.post).not.toHaveBeenCalled(); + }); + + it('should set isDeepLinking to false if the response status is not 200', fakeAsync(() => { + const replaceMock = jest.fn(); + Object.defineProperty(window, 'location', { + value: { replace: replaceMock }, + writable: true, + }); + component.selectedExercise = exercise1; + component.courseId = 123; + const nonSuccessResponse = new HttpResponse({ + status: 400, + body: { message: 'Bad request' }, + }); + httpMock.post.mockReturnValue(of(nonSuccessResponse)); + + component.sendDeepLinkRequest(); + tick(); + + expect(component.isLinking).toBeFalse(); + expect(httpMock.post).toHaveBeenCalledWith(`api/lti13/deep-linking/${component.courseId}`, null, { + observe: 'response', + params: new HttpParams().set('exerciseId', exercise1.id!).set('ltiIdToken', '').set('clientRegistrationId', ''), + }); + expect(replaceMock).not.toHaveBeenCalled(); // Verify that we did not navigate + })); + + it('should set isLinking to false if there is an error during the HTTP request', fakeAsync(() => { + component.selectedExercise = exercise1; + component.courseId = 123; + const mockError = new Error('Network error'); + httpMock.post.mockReturnValue(throwError(() => mockError)); + + component.sendDeepLinkRequest(); + tick(); + + expect(component.isLinking).toBeFalse(); + expect(httpMock.post).toHaveBeenCalledWith(`api/lti13/deep-linking/${component.courseId}`, null, { + observe: 'response', + params: new HttpParams().set('exerciseId', exercise1.id!).set('ltiIdToken', '').set('clientRegistrationId', ''), + }); + })); + + it('should send deep link request and navigate when exercise is selected', () => { + const replaceMock = jest.fn(); + Object.defineProperty(window, 'location', { + value: { replace: replaceMock }, + writable: true, + }); + component.selectedExercise = exercise1; + component.courseId = 123; + + const mockResponse = new HttpResponse({ + status: 200, + body: { targetLinkUri: 'http://example.com/deep_link' }, + }); + + httpMock.post.mockReturnValue(of(mockResponse)); + component.sendDeepLinkRequest(); + + expect(httpMock.post).toHaveBeenCalledWith(`api/lti13/deep-linking/${component.courseId}`, null, { + observe: 'response', + params: new HttpParams().set('exerciseId', exercise1.id!).set('ltiIdToken', '').set('clientRegistrationId', ''), + }); + }); +}); diff --git a/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts b/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts index d46307a46cd3..1386387a6436 100644 --- a/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts +++ b/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts @@ -9,6 +9,8 @@ import { LoginService } from 'app/core/login/login.service'; import { AccountService } from 'app/core/auth/account.service'; import { MockAccountService } from '../../helpers/mocks/service/mock-account.service'; import { User } from 'app/core/user/user.model'; +import { SessionStorageService } from 'ngx-webstorage'; +import { MockSyncStorage } from '../../helpers/mocks/service/mock-sync-storage.service'; describe('Lti13ExerciseLaunchComponent', () => { let fixture: ComponentFixture; @@ -36,6 +38,7 @@ describe('Lti13ExerciseLaunchComponent', () => { { provide: LoginService, useValue: loginService }, { provide: AccountService, useClass: MockAccountService }, { provide: Router, useValue: mockRouter }, + { provide: SessionStorageService, useClass: MockSyncStorage }, ], }) .compileComponents() @@ -97,14 +100,14 @@ describe('Lti13ExerciseLaunchComponent', () => { }); it('onInit no targetLinkUri', () => { - const httpStub = jest.spyOn(http, 'post').mockReturnValue(of({})); + const httpStub = jest.spyOn(http, 'post').mockReturnValue(of({ ltiIdToken: 'id-token', clientRegistrationId: 'client-id' })); const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); expect(comp.isLaunching).toBeTrue(); comp.ngOnInit(); - expect(consoleSpy).toHaveBeenCalledOnce(); + expect(consoleSpy).toHaveBeenCalled(); expect(consoleSpy).toHaveBeenCalledWith('No LTI targetLinkUri received for a successful launch'); expect(httpStub).toHaveBeenCalledOnce(); expect(httpStub).toHaveBeenCalledWith('api/public/lti13/auth-login', expect.anything(), expect.anything()); @@ -114,7 +117,7 @@ describe('Lti13ExerciseLaunchComponent', () => { it('onInit success to call launch endpoint', () => { const targetLink = window.location.host + '/targetLink'; - const httpStub = jest.spyOn(http, 'post').mockReturnValue(of({ targetLinkUri: targetLink })); + const httpStub = jest.spyOn(http, 'post').mockReturnValue(of({ targetLinkUri: targetLink, ltiIdToken: 'id-token', clientRegistrationId: 'client-id' })); expect(comp.isLaunching).toBeTrue(); @@ -198,8 +201,8 @@ describe('Lti13ExerciseLaunchComponent', () => { const httpStub = jest.spyOn(http, 'post').mockReturnValue( throwError(() => ({ status, - headers: { get: () => 'mockTargetLinkUri', ...headers }, - error, + headers: { get: () => 'lti_user', ...headers }, + error: { targetLinkUri: 'mockTargetLinkUri', ...error }, })), ); return httpStub; diff --git a/src/test/javascript/spec/component/lti/lti13-select-content.component.spec.ts b/src/test/javascript/spec/component/lti/lti13-select-content.component.spec.ts new file mode 100644 index 000000000000..f4715f47f7b3 --- /dev/null +++ b/src/test/javascript/spec/component/lti/lti13-select-content.component.spec.ts @@ -0,0 +1,78 @@ +import { Lti13SelectContentComponent } from 'app/lti/lti13-select-content.component'; +import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { of } from 'rxjs'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { SafeResourceUrlPipe } from 'app/shared/pipes/safe-resource-url.pipe'; +import { MockPipe } from 'ng-mocks'; + +describe('Lti13SelectContentComponent', () => { + let component: Lti13SelectContentComponent; + let fixture: ComponentFixture; + let routeMock: any; + + beforeEach(waitForAsync(() => { + routeMock = { + snapshot: { + queryParamMap: { + get: jest.fn(), + }, + }, + params: of({}), + }; + + TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, FormsModule], + declarations: [Lti13SelectContentComponent, MockPipe(ArtemisTranslatePipe), MockPipe(SafeResourceUrlPipe)], + providers: [FormBuilder, { provide: ActivatedRoute, useValue: routeMock }], + }).compileComponents(); + })); + + beforeEach(() => { + HTMLFormElement.prototype.submit = jest.fn(); + fixture = TestBed.createComponent(Lti13SelectContentComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should initialize form on ngOnInit', fakeAsync(() => { + const jwt = 'jwt_token'; + const id = 'id_token'; + const deepLinkUri = 'http://example.com/deep_link'; + + routeMock.snapshot.queryParamMap.get.mockImplementation((param: string) => { + switch (param) { + case 'jwt': + return jwt; + case 'id': + return id; + case 'deepLinkUri': + return deepLinkUri; + default: + return null; + } + }); + + component.ngOnInit(); + tick(); + + expect(component.actionLink).toBe(deepLinkUri); + expect(component.isLinking).toBeTrue(); + })); + + it('should not auto-submit form if parameters are missing', fakeAsync(() => { + routeMock.snapshot.queryParamMap.get.mockReturnValue(null); + const autoSubmitSpy = jest.spyOn(component, 'autoSubmitForm'); + + component.ngOnInit(); + tick(); + + expect(component.isLinking).toBeFalse(); + expect(autoSubmitSpy).not.toHaveBeenCalled(); + })); +});