Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@

application-local*.properties

google-cloud-credentials.json

# Locally generated certificates
*.key
*.pem
Expand Down
92 changes: 92 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,95 @@ Using Ngrok For Local SSL Cert
3. Utilize the https url from ngrok when registering your tool with the platform

Note: Each time you restart ngrok, you will need to change the url of your tool in your registration with the LMS. However, you may restart the tool as much as you like while leaving ngrok running without issue.

Google Classroom Adapter
------------------------
------------------------
Developer/Admin Setup in Google Cloud
-------------------------------------
Source: https://developers.google.com/classroom/quickstart/java
1. Go to the Google Cloud Console: https://console.cloud.google.com/
2. Click on the dropdown to the right of "Google Cloud" to open the pop-up to select a project.
3. Click on NEW PROJECT in the upper right hand corner.
4. Enter a project name and ensure the other fields have valid values, then click CREATE.
5. Wait for the project to be created, then click SELECT PROJECT.
6. Your project's name should now appear to the right of "Google Cloud" at the top of the page.
7. Go here: https://console.cloud.google.com/flows/enableapi?apiid=classroom.googleapis.com
8. Ensure that you still see your project's name at the top of the page and then click NEXT.
9. Click ENABLE.
10. In the Google Cloud console, go to Menu menu > APIs & Services > Credentials.
11. Click CREATE CREDENTIALS > OAuth client ID.
12. Click CONFIGURE CONSENT SCREEN.
13. Select your User Type (I chose Internal) and click CREATE.
14. Enter a name for your application, a User Support email, and a Developer email address. I left the remaining fields with blank/default values. Click SAVE & CONTINUE.
15. Click ADD OR REMOVE SCOPES.
16. Enter the following list of scopes:
```
https://www.googleapis.com/auth/classroom.announcements
https://www.googleapis.com/auth/classroom.announcements.readonly
https://www.googleapis.com/auth/classroom.courses
https://www.googleapis.com/auth/classroom.courses.readonly
https://www.googleapis.com/auth/classroom.coursework.me
https://www.googleapis.com/auth/classroom.coursework.me.readonly
https://www.googleapis.com/auth/classroom.coursework.students
https://www.googleapis.com/auth/classroom.coursework.students.readonly
https://www.googleapis.com/auth/classroom.courseworkmaterials
https://www.googleapis.com/auth/classroom.courseworkmaterials.readonly
https://www.googleapis.com/auth/classroom.guardianlinks.me.readonly
https://www.googleapis.com/auth/classroom.guardianlinks.students
https://www.googleapis.com/auth/classroom.guardianlinks.students.readonly
https://www.googleapis.com/auth/classroom.profile.emails
https://www.googleapis.com/auth/classroom.profile.photos
https://www.googleapis.com/auth/classroom.push-notifications
https://www.googleapis.com/auth/classroom.rosters
https://www.googleapis.com/auth/classroom.rosters.readonly
https://www.googleapis.com/auth/classroom.student-submissions.me.readonly
https://www.googleapis.com/auth/classroom.student-submissions.students.readonly
https://www.googleapis.com/auth/classroom.topics
https://www.googleapis.com/auth/classroom.topics.readonly
```
17. Use the left/right arrow buttons to navigate through the scopes (I see 48 total). I also checked the boxes for service.management and service.management.readonly, userinfo.email, userinfo.profile, and openid. Click UPDATE.
18. Click SAVE AND CONTINUE.
19. Review the summary then click BACK TO DASHBOARD.
20. In the Google Cloud console, go to Menu menu > APIs & Services > Credentials.
21. Click CREATE CREDENTIALS > OAuth client ID.
22. Click Application type > Web application.
23. In the Name field, type a name for the credential. This name is only shown in the Google Cloud console.
24. For Authorized Redirect URIs, enter `http://localhost:8888` (Ensure in your properties file that you have `management.port=8888`)
25. Click Create. The OAuth client created screen appears, showing your new Client ID and Client secret.
26. Click OK. The newly created credential appears under OAuth 2.0 Client IDs.
27. Save the downloaded JSON file as google-cloud-credentials.json, and move the file to src/main/resources.

Add Hardcoded Self PlatformDeployment to DB
-------------------------------------------
In Postman, use the `/config` endpoint and the following request body to add the self platform deployment. Ensure to change out the domain with your own ngrok domain.
```json
{
"iss": "https://7ae3-184-101-4-99.ngrok.io",
"clientId": "self-client-id",
"oidcEndpoint": "https://7ae3-184-101-4-99.ngrok.io/app/platform-oidc-authorize",
"jwksEndpoint": "https://7ae3-184-101-4-99.ngrok.io/jwks/jwk",
"oAuth2TokenUrl": "",
"oAuth2TokenAud": "",
"deploymentId": "self-deployment-id"
}
```

Overview
--------
Hopefully after completing the steps in the previous 2 sections, you should be ready to use the Google Classroom Adapter.
The Google Classroom adapter supports Classwork insertion (Deep Linking) and standard LTI launches from Google Classroom. It does not support grade passback or memberships (NRPS).
Replacing the example ngrok domain with your own ngrok domain, the flow should be as follows:
1. In your browser, go to https://7ae3-184-101-4-99.ngrok.io/app
2. If this is your first time using the app, then there will be a Google Auth link in the logs. Copy/paste that link into a separate tab in your browser.
3. Complete the Google Auth process and close the tab for it.
4. Return to your App tab.
5. Select your Google Classroom class from the dropdown (if you don't have one, create one and then refresh this page).
6. Click the "Add Content to LMS" button.
7. Complete the demo LTI flow to get to the Deep Linking menu.
8. Select which link(s) you would like to insert into your Google Classroom class.
9. A success page should appear.
10. In another tab, open your Google Classroom class and click on the Classwork tab.
11. Your link(s) should be there.
12. Click on one of the links.
13. Complete the demo LTI flow to receive a "Google Classroom" LTI id_token.
17 changes: 17 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,23 @@
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.9</version>
</dependency>

<!-- Google Classroom -->
<dependency>
<groupId>com.google.api-client</groupId>
<artifactId>google-api-client</artifactId>
<version>2.0.0</version>
</dependency>
<dependency>
<groupId>com.google.oauth-client</groupId>
<artifactId>google-oauth-client-jetty</artifactId>
<version>1.34.1</version>
</dependency>
<dependency>
<groupId>com.google.apis</groupId>
<artifactId>google-api-services-classroom</artifactId>
<version>v1-rev20220323-2.0.0</version>
</dependency>
</dependencies>

<profiles>
Expand Down
1 change: 1 addition & 0 deletions src/main/java/net/unicon/lti/config/WebSecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ protected void configure(HttpSecurity http) throws Exception {
.antMatchers("/registration/**")
.antMatchers("/jwks/**")
.antMatchers("/ags/**")
.antMatchers("/app/**")
.and()
.authorizeRequests().anyRequest().permitAll().and().csrf().disable().headers().frameOptions().disable();
}
Expand Down
126 changes: 126 additions & 0 deletions src/main/java/net/unicon/lti/controller/app/AppController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package net.unicon.lti.controller.app;

import com.google.api.services.classroom.model.Course;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import lombok.extern.slf4j.Slf4j;
import net.unicon.lti.model.GcCourseEntity;
import net.unicon.lti.model.GcLinkEntity;
import net.unicon.lti.model.GcUserEntity;
import net.unicon.lti.model.lti.dto.LoginInitiationDTO;
import net.unicon.lti.service.gc.GoogleClassroomService;
import net.unicon.lti.service.lti.LTIDataService;
import net.unicon.lti.service.lti.LTIJWTService;
import net.unicon.lti.utils.TextConstants;
import net.unicon.lti.utils.lti.LtiOidcUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.util.List;

import static net.unicon.lti.utils.TextConstants.LTI3_SUFFIX;

@Controller
@Scope("session")
@RequestMapping("/app")
@Slf4j
public class AppController {
@Autowired
LTIDataService ltiDataService;

@Autowired
LTIJWTService ltijwtService;

@Autowired
GoogleClassroomService googleClassroomService;

// Launches a mock standalone home page with a button to start the LTI DL --> GC CW flow.
@RequestMapping({"", "/{linkUuid}"})
public String appInit(HttpServletRequest req, Model model, @PathVariable(value = "linkUuid", required = false) String linkUuid, @RequestParam(value = "link", required = false) String link) throws GeneralSecurityException, IOException {
String targetUri = ltiDataService.getLocalUrl() + TextConstants.LTI3_SUFFIX;
if (linkUuid != null) {
targetUri = targetUri + "?gcLink=" + linkUuid;
if (link != null) {
// TODO: Ultimately I end up letting the original link id just fall by the wayside. That should probably change in the end.
targetUri = targetUri + "&link=" + link;
}
}

LoginInitiationDTO loginInitiationDTO = new LoginInitiationDTO(
ltiDataService.getLocalUrl(),
"uuid or user's gc course id",
targetUri,
LtiOidcUtils.generateLtiMessageHint(ltiDataService, linkUuid, link),
"self-client-id",
"self-deployment-id"
);
model.addAttribute("oidcRequestUrl", ltiDataService.getLocalUrl() + "/oidc/login_initiations");
model.addAttribute("oidcLoginRequest", loginInitiationDTO);

if (StringUtils.isBlank(linkUuid)) { // if no linkUuid then go to "home page" to pick course for content selection (Deep Linking) launch
List<Course> courses = googleClassroomService.getCoursesFromGoogleClassroom();
model.addAttribute("courses", courses);
model.addAttribute("title", "App!");
log.debug("Going to home page to pick course for content selection...");
return "app";
} else { // if linkUuid present, do LTI launch to specific link
log.debug("Sending platform OIDC Login Request...");
return "platformOIDCLoginRequest";
}
}

// Acts as a platform's LTI auth response containing an id_token with a Deep Linking message type.
@RequestMapping({"/platform-oidc-authorize"})
public String generatePlatformOidcAuthorization(HttpServletRequest req, Model model) throws GeneralSecurityException {
log.debug("Received platform authorization request.");
// TODO validate nonce
Jws<Claims> jwt = ltijwtService.validateState(req.getParameter("lti_message_hint"));
String linkUuid = jwt.getBody().get("linkUuid") != null ? jwt.getBody().get("linkUuid").toString() : null;
model.addAttribute("state", req.getParameter("state"));
String target = ltiDataService.getLocalUrl() + LTI3_SUFFIX;

GcUserEntity gcUserEntity = googleClassroomService.getCurrentUser(linkUuid);
GcLinkEntity gcLinkEntity = googleClassroomService.getGcLinkByUuid(linkUuid);

if (linkUuid == null) { // deep linking flow
log.debug("Preparing deep linking flow...");
GcCourseEntity gcCourseEntity = googleClassroomService.getGcCourseByGcCourseId(req.getParameter("login_hint"));
model.addAttribute("id_token", LtiOidcUtils.generateLtiIdToken(ltiDataService, req.getParameter("nonce"), gcUserEntity, gcCourseEntity, gcLinkEntity, true));
} else { // resource link flow
log.debug("Preparing resource link flow...");
GcCourseEntity gcCourseEntity = googleClassroomService.getGcCourseFromLinkId(linkUuid);
target = target + "?link=" + linkUuid;
// TODO: May want to query GC to ensure that gcCourse is up to date in db before generating id_token
model.addAttribute("id_token", LtiOidcUtils.generateLtiIdToken(ltiDataService, req.getParameter("nonce"), gcUserEntity, gcCourseEntity, gcLinkEntity, false));
}
model.addAttribute("target", target);
log.debug("Sending platform auth response with id_token...");
return "platformAuthResponse";
}

// Handles deep linking responses, converting them into Google Classroom Coursework
@RequestMapping({"/gccoursework/{gcCourseId}"})
public String gcCourseWork(HttpServletRequest req, Model model, @PathVariable("gcCourseId") String gcCourseId) throws GeneralSecurityException, IOException {
// validate and parse jwt
Jws<Claims> jwt = ltijwtService.validateState(req.getParameter("JWT"));
if (jwt == null) {
model.addAttribute("Error", "Could not validate or parse JWT.");
return "app";
}

// insert the links into that course
googleClassroomService.addClassworkMaterials(gcCourseId, jwt);

model.addAttribute("title", "Your assignments have been added to Google Classroom!");
return "gcAddMaterialsResponse";
}
}
92 changes: 92 additions & 0 deletions src/main/java/net/unicon/lti/model/GcCourseEntity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package net.unicon.lti.model;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import javax.persistence.Basic;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

@Entity
@Table(name = "gc_course")
@Setter
@Getter
@NoArgsConstructor
public class GcCourseEntity {
// Internal id for the course
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private long id;

// Google's id for the course
@Basic
@Column(name = "gc_course_id", nullable = false, length = 4096)
private String gcCourseId;

@Basic
@Column(name = "name", length = 4096)
private String name;
@Basic
@Column(name = "section")
private String section;
@Basic
@Column(name = "description_heading")
private String descriptionHeading;
@Basic
@Column(name = "description")
private String description;
@Basic
@Column(name = "room")
private String room;
@Basic
@Column(name = "owner_id")
private String ownerId;
@Basic
@Column(name = "creation_time")
private String creationTime;
@Basic
@Column(name = "update_time")
private String updateTime;
@Basic
@Column(name = "enrollment_code")
private String enrollmentCode;
@Basic
@Column(name = "course_state")
private String courseState;
@Basic
@Column(name = "alternate_link")
private String alternateLink;
@Basic
@Column(name = "teacher_group_email")
private String teacherGroupEmail;
@Basic
@Column(name = "course_group_email")
private String courseGroupEmail;
@Basic
@Column(name = "guardians_enabled")
private boolean guardiansEnabled;

public GcCourseEntity(String gcCourseId, String name, String section, String descriptionHeading, String description, String room, String ownerId, String creationTime, String updateTime, String enrollmentCode, String courseState, String alternateLink, String teacherGroupEmail, String courseGroupEmail, boolean guardiansEnabled) {
this.gcCourseId = gcCourseId;
this.name = name;
this.section = section;
this.descriptionHeading = descriptionHeading;
this.description = description;
this.room = room;
this.ownerId = ownerId;
this.creationTime = creationTime;
this.updateTime = updateTime;
this.enrollmentCode = enrollmentCode;
this.courseState = courseState;
this.alternateLink = alternateLink;
this.teacherGroupEmail = teacherGroupEmail;
this.courseGroupEmail = courseGroupEmail;
this.guardiansEnabled = guardiansEnabled;
}
}
Loading