Browse Source

Initial Command+Message->dispatcher->response implementation

gremlin 4 years ago
parent
commit
a3a59d43e2
29 changed files with 929 additions and 2 deletions
  1. 5 0
      .gitignore
  2. 121 0
      pom.xml
  3. 87 0
      src/main/java/riomhaire/lti/JavaLti13ToolApplication.java
  4. 26 1
      src/main/java/riomhaire/lti/Registry.java
  5. 55 0
      src/main/java/riomhaire/lti/core/adapters/token/JWKBasedJwtToMapAdapter.java
  6. 57 0
      src/main/java/riomhaire/lti/core/business/LTIMessageDispatcher.java
  7. 70 0
      src/main/java/riomhaire/lti/core/business/commands/InitiateOidcCommand.java
  8. 100 0
      src/main/java/riomhaire/lti/core/business/commands/LTILaunchCommand.java
  9. 33 0
      src/main/java/riomhaire/lti/core/business/commands/LaunchContentCommand.java
  10. 23 0
      src/main/java/riomhaire/lti/core/infrastructure/api/Endpoint.java
  11. 30 0
      src/main/java/riomhaire/lti/core/infrastructure/api/tool/LaunchEndpoint.java
  12. 32 0
      src/main/java/riomhaire/lti/core/infrastructure/api/tool/OIDCEndpoint.java
  13. 19 0
      src/main/java/riomhaire/lti/core/infrastructure/configuration/DecoupledLogicSetup.java
  14. 15 0
      src/main/java/riomhaire/lti/core/infrastructure/facades/clientregistration/ConfigBasedClientRegistrationResolver.java
  15. 34 0
      src/main/java/riomhaire/lti/core/infrastructure/facades/clientregistration/RedisBasedClientRegistrationResolver.java
  16. 23 0
      src/main/java/riomhaire/lti/core/model/ClientConfiguration.java
  17. 14 1
      src/main/java/riomhaire/lti/core/model/LTIMessage.java
  18. 8 0
      src/main/java/riomhaire/lti/core/model/LTIMessageType.java
  19. 21 0
      src/main/java/riomhaire/lti/core/model/interfaces/ApplicationRegistry.java
  20. 9 0
      src/main/java/riomhaire/lti/core/model/interfaces/ClientRegistrationResolver.java
  21. 12 0
      src/main/java/riomhaire/lti/core/model/interfaces/Command.java
  22. 8 0
      src/main/java/riomhaire/lti/core/model/interfaces/CommandDispatcher.java
  23. 4 0
      src/main/java/riomhaire/lti/core/model/interfaces/DecodeException.java
  24. 6 0
      src/main/java/riomhaire/lti/core/model/interfaces/Decoder.java
  25. 10 0
      src/main/resources/application.properties
  26. 71 0
      src/main/resources/templates/dump-launch.html
  27. 12 0
      src/main/resources/templates/error.html
  28. 16 0
      src/main/resources/templates/nop.html
  29. 8 0
      src/redis/lti-client-registration-fc478730296ffab709218f28a22f0658.json

+ 5 - 0
.gitignore

@@ -12,3 +12,8 @@
 # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
 hs_err_pid*
 
+
+.idea/
+
+*.iml
+/target/

+ 121 - 0
pom.xml

@@ -0,0 +1,121 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+	<modelVersion>4.0.0</modelVersion>
+	<parent>
+		<groupId>org.springframework.boot</groupId>
+		<artifactId>spring-boot-starter-parent</artifactId>
+		<version>2.4.3</version>
+		<relativePath/> <!-- lookup parent from repository -->
+	</parent>
+	<groupId>lti</groupId>
+	<artifactId>tool-13-cmr</artifactId>
+	<version>0.0.1-SNAPSHOT</version>
+	<name>java-lti13-tool-cmr</name>
+	<description></description>
+	<properties>
+		<java.version>14</java.version>
+	</properties>
+	<dependencies>
+		<dependency>
+			<groupId>com.auth0</groupId>
+			<artifactId>java-jwt</artifactId>
+			<version>3.10.0</version>
+		</dependency>
+		<dependency>
+			<groupId>org.bitbucket.b_c</groupId>
+			<artifactId>jose4j</artifactId>
+			<version>0.7.0</version>
+		</dependency>
+		<dependency>
+			<groupId>org.springframework.boot</groupId>
+			<artifactId>spring-boot-starter-web</artifactId>
+		</dependency>
+		<!-- https://mvnrepository.com/artifact/org.thymeleaf/thymeleaf-spring5 -->
+		<dependency>
+			<groupId>org.thymeleaf</groupId>
+			<artifactId>thymeleaf-spring5</artifactId>
+			<version>3.0.12.RELEASE</version>
+		</dependency>
+
+		<dependency>
+			<groupId>org.springframework.boot</groupId>
+			<artifactId>spring-boot-starter-thymeleaf</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>org.springframework.boot</groupId>
+			<artifactId>spring-boot-configuration-processor</artifactId>
+			<optional>true</optional>
+		</dependency>
+		<dependency>
+			<groupId>org.springframework.boot</groupId>
+			<artifactId>spring-boot-starter-test</artifactId>
+			<scope>test</scope>
+			<exclusions>
+				<exclusion>
+					<groupId>org.junit.vintage</groupId>
+					<artifactId>junit-vintage-engine</artifactId>
+				</exclusion>
+			</exclusions>
+		</dependency>
+
+		<dependency>
+			<groupId>org.springframework.boot</groupId>
+			<artifactId>spring-boot-starter-actuator</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>org.springframework.boot</groupId>
+			<artifactId>spring-boot-starter-cache</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>org.springframework.boot</groupId>
+			<artifactId>spring-boot-starter-data-redis</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>org.springframework.boot</groupId>
+			<artifactId>spring-boot-starter-integration</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>org.springframework.integration</groupId>
+			<artifactId>spring-integration-redis</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>org.projectlombok</groupId>
+			<artifactId>lombok</artifactId>
+			<optional>true</optional>
+		</dependency>
+
+		<dependency>
+			<groupId>org.springframework.integration</groupId>
+			<artifactId>spring-integration-test</artifactId>
+			<scope>test</scope>
+		</dependency>
+	</dependencies>
+
+	<build>
+		<plugins>
+			<plugin>
+				<groupId>org.springframework.boot</groupId>
+				<artifactId>spring-boot-maven-plugin</artifactId>
+				<configuration>
+					<excludes>
+						<exclude>
+							<groupId>org.projectlombok</groupId>
+							<artifactId>lombok</artifactId>
+						</exclude>
+					</excludes>
+				</configuration>
+			</plugin>
+			<plugin>
+				<groupId>org.apache.maven.plugins</groupId>
+				<artifactId>maven-compiler-plugin</artifactId>
+				<configuration>
+					<source>14</source>
+					<target>14</target>
+<!--					<compilerArgs>&#45;&#45;enable-preview</compilerArgs>-->
+				</configuration>
+			</plugin>
+		</plugins>
+	</build>
+
+</project>

+ 87 - 0
src/main/java/riomhaire/lti/JavaLti13ToolApplication.java

@@ -0,0 +1,87 @@
+package riomhaire.lti;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.lang.NonNull;
+import org.springframework.web.servlet.ModelAndView;
+import riomhaire.lti.core.business.LTIMessageDispatcher;
+import riomhaire.lti.core.business.commands.InitiateOidcCommand;
+import riomhaire.lti.core.business.commands.LTILaunchCommand;
+import riomhaire.lti.core.business.commands.LaunchContentCommand;
+import riomhaire.lti.core.infrastructure.facades.clientregistration.RedisBasedClientRegistrationResolver;
+import riomhaire.lti.core.model.LTIMessage;
+import riomhaire.lti.core.model.LTIMessageType;
+import riomhaire.lti.core.model.interfaces.ApplicationRegistry;
+import riomhaire.lti.core.model.interfaces.ClientRegistrationResolver;
+import riomhaire.lti.core.model.interfaces.CommandDispatcher;
+
+/**
+ * This is the main entry point for the LTI Tool and well as salient configuration of core important items.
+ *
+ * In this simplified implementation we have:
+ *
+ *  [Application Registry] Which contains information about environment and services the commands can execute - everything is in one place.
+ *       -
+ */
+@SpringBootApplication
+@Configuration
+public class JavaLti13ToolApplication {
+
+    /**
+     * This is the entry point - eveything starts here
+     * @param args command line arguments
+     */
+    public static void main(String[] args) {
+        SpringApplication.run(JavaLti13ToolApplication.class, args);
+    }
+
+    /**
+     * This builds the implementation of the service which allows us to look up a deployed and registered tool within
+     * our system by default we store a configuration within redis ... see src/redis folder for an example
+     * @param template this is the template engine which allows us to interact with redis - provided by spring
+     * @return ClientRegistrationResolver which allows us to look up a client registration based on certain parameters
+     */
+    @Bean
+    ClientRegistrationResolver buildClientRegistrationResolver(RedisTemplate<String, String> template) {
+        return new RedisBasedClientRegistrationResolver(template);
+    }
+
+    /**
+     * This Spring bean creates the command dispatcher and populates it for the current tool .
+     * It is here where you map messageType to command which handles it
+     *
+     * Currently defined messages are:
+     *
+     *   - "ProcessOidcRequest"      : This is the command launched when the 'initiate' oidc launch command
+     *   - "ProcessLTIMessage"       : LTI Launch-content/deep-link are contained within a JWT.. this command
+     *                                 decodes the JWT and delegates to 'LaunchContent', 'SelectDeepLinkContent' etc
+     *   - "LaunchContent"           : This is the message associated with launch a basic resource
+     *   - "SelectDeepLinkContent"   : This is the message sent to handle 'we need to select content for a deep link'
+     *
+     * @return the Spring View-Model result of applyings a command to a message
+     */
+    @Bean
+    CommandDispatcher<ModelAndView, ApplicationRegistry, LTIMessage> buildCommandDispatcher() {
+        return new LTIMessageDispatcher()
+                .addCommand(LTIMessageType.ProcessOidcRequest.name(), new InitiateOidcCommand())
+                .addCommand(LTIMessageType.ProcessLTIMessage.name(), new LTILaunchCommand())
+                .addCommand(LTIMessageType.LaunchContent.name(), new LaunchContentCommand());
+
+    }
+
+    /**
+     * This builds the registry for this tool/application
+     *
+     * @param commandDispatcher            Dispatches a message to an appropriate command
+     * @param clientRegistrationResolver   Look up the configuration for a particular client
+     * @return Registry            environment links  we execute within
+     */
+    @Bean
+    public Registry buildRegistry(@NonNull CommandDispatcher<ModelAndView, ApplicationRegistry, LTIMessage> commandDispatcher, @NonNull ClientRegistrationResolver clientRegistrationResolver) {
+        return new Registry(commandDispatcher, clientRegistrationResolver);
+    }
+
+}

+ 26 - 1
src/main/java/riomhaire/lti/Registry.java

@@ -1,2 +1,27 @@
-package riomhaire.lti;public class Registry {
+package riomhaire.lti;
+
+import lombok.AllArgsConstructor;
+import org.springframework.web.servlet.ModelAndView;
+import riomhaire.lti.core.model.LTIMessage;
+import riomhaire.lti.core.model.interfaces.ApplicationRegistry;
+import riomhaire.lti.core.model.interfaces.ClientRegistrationResolver;
+import riomhaire.lti.core.model.interfaces.CommandDispatcher;
+
+@AllArgsConstructor
+public class Registry implements ApplicationRegistry {
+
+    private CommandDispatcher<ModelAndView, ApplicationRegistry, LTIMessage> commandDispatcher;
+    private ClientRegistrationResolver clientRegistrationResolver;
+
+
+    @Override
+    public ClientRegistrationResolver clientRegistrationResolver() {
+        return clientRegistrationResolver;
+    }
+
+    @Override
+    public CommandDispatcher<ModelAndView, ApplicationRegistry, LTIMessage> commandDispatcher() {
+        return commandDispatcher;
+    }
 }
+

+ 55 - 0
src/main/java/riomhaire/lti/core/adapters/token/JWKBasedJwtToMapAdapter.java

@@ -0,0 +1,55 @@
+package riomhaire.lti.core.adapters.token;
+
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.interfaces.DecodedJWT;
+import lombok.extern.slf4j.Slf4j;
+import org.jose4j.jwk.HttpsJwks;
+import org.jose4j.jwt.JwtClaims;
+import org.jose4j.jwt.consumer.InvalidJwtException;
+import org.jose4j.jwt.consumer.JwtConsumer;
+import org.jose4j.jwt.consumer.JwtConsumerBuilder;
+import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver;
+import riomhaire.lti.core.model.interfaces.DecodeException;
+import riomhaire.lti.core.model.interfaces.Decoder;
+
+import java.util.Map;
+
+@Slf4j
+public class JWKBasedJwtToMapAdapter implements Decoder<Map<String, Object>, String> {
+
+    protected final String jwksUrl;
+    protected final boolean skipVerification;
+
+    public JWKBasedJwtToMapAdapter(String jwksUrl, boolean skipVerification) {
+        this.jwksUrl = jwksUrl;
+        this.skipVerification = skipVerification;
+    }
+
+    public Map<String, Object> decode(String token) throws DecodeException {
+        DecodedJWT jwt = JWT.decode(token);
+        JwtClaims verifiedClaims;
+
+
+        String clientId = jwt.getClaim("aud").asString();
+
+
+        try {
+            // OK look up jwks and verify
+            log.trace("Using key:" + jwt.getKeyId() + " to search: " + jwksUrl);
+            HttpsJwks httpsJkws = new HttpsJwks(jwksUrl);
+            HttpsJwksVerificationKeyResolver httpsJwksKeyResolver = new HttpsJwksVerificationKeyResolver(httpsJkws);
+            JwtConsumer jwtConsumer = new JwtConsumerBuilder()
+                    .setVerificationKeyResolver(httpsJwksKeyResolver)
+                    .setExpectedAudience(clientId)
+                    .build();
+            if (skipVerification) { // Dont verify
+                jwtConsumer.setSkipVerificationKeyResolutionOnNone(true);
+            }
+            verifiedClaims = jwtConsumer.processToClaims(token);
+        } catch (InvalidJwtException e) {
+            throw new DecodeException();
+        }
+        // OK were here ... so valid
+        return verifiedClaims.getClaimsMap();
+    }
+}

+ 57 - 0
src/main/java/riomhaire/lti/core/business/LTIMessageDispatcher.java

@@ -0,0 +1,57 @@
+package riomhaire.lti.core.business;
+
+import lombok.NoArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.servlet.ModelAndView;
+import riomhaire.lti.core.model.LTIMessage;
+import riomhaire.lti.core.model.interfaces.ApplicationRegistry;
+import riomhaire.lti.core.model.interfaces.Command;
+import riomhaire.lti.core.model.interfaces.CommandDispatcher;
+
+import java.util.HashMap;
+
+/**
+ * This is a simple implementation of the classic command matcher pattern where
+ * we map a message to one command handler. This allows us to decouple the handler of the message from the sender.
+ * We could extend this where necessary
+ */
+@Slf4j
+@NoArgsConstructor
+public class LTIMessageDispatcher implements CommandDispatcher<ModelAndView, ApplicationRegistry, LTIMessage> {
+    // This stores the mapping of command to command handler
+    private final HashMap<String, Command<ModelAndView, ApplicationRegistry, LTIMessage>> commands = new HashMap<>();
+
+    // This is the action that is (optionally) executed if no match is made
+    private Command<ModelAndView, ApplicationRegistry, LTIMessage> defaultCommand;
+
+    /**
+     * Adds a command handler for goven command
+     *
+     * @param commandName we allow same command to handle multiple requests
+     * @param command     does the work
+     * @return reference to dispatcher to allow fluent style usage.
+     */
+    @Override
+    public CommandDispatcher<ModelAndView, ApplicationRegistry, LTIMessage> addCommand(String commandName, Command<ModelAndView, ApplicationRegistry, LTIMessage> command) {
+        commands.put(commandName, command);
+        return this;
+    }
+
+
+    @Override
+    public ModelAndView dispatch(ApplicationRegistry registry, LTIMessage message) {
+        var mav = new ModelAndView("nop");
+        // If we have a handler for this command execute it
+        if (message != null && commands.containsKey(message.getMessageType())) {
+            mav = commands.get(message.getMessageType()).execute(registry, message);
+        } else if (defaultCommand != null) {
+            mav = defaultCommand.execute(registry, message);
+        }
+        return mav;
+    }
+
+    public void setDefaultCommand(Command<ModelAndView, ApplicationRegistry, LTIMessage> defaultCommand) {
+        this.defaultCommand = defaultCommand;
+    }
+
+}

+ 70 - 0
src/main/java/riomhaire/lti/core/business/commands/InitiateOidcCommand.java

@@ -0,0 +1,70 @@
+package riomhaire.lti.core.business.commands;
+
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.servlet.ModelAndView;
+import org.springframework.web.servlet.view.RedirectView;
+import org.springframework.web.servlet.view.json.MappingJackson2JsonView;
+import riomhaire.lti.core.model.LTIMessage;
+import riomhaire.lti.core.model.interfaces.ApplicationRegistry;
+import riomhaire.lti.core.model.interfaces.Command;
+
+import java.util.UUID;
+
+@AllArgsConstructor
+@Slf4j
+public class InitiateOidcCommand implements Command<ModelAndView, ApplicationRegistry, LTIMessage> {
+
+
+    public ModelAndView execute(ApplicationRegistry registry, LTIMessage message) {
+        final ModelAndView mav;
+        var queryParams = message.getQueryParams();
+        String iss = queryParams.get("iss");
+        String clientId = queryParams.get("client_id");
+        var clientRegistration = registry.clientRegistrationResolver().lookupClient(iss, clientId);
+
+        if (clientRegistration.isPresent()) {
+            // Build Location
+            StringBuilder location = new StringBuilder();
+            var clientConfiguration = clientRegistration.get();
+            var loginHint = queryParams.get("login_hint");
+            var ltiMessageHint = queryParams.get("lti_message_hint");
+            var targetLinkUri = queryParams.get("target_link_uri");
+
+
+            location.append(clientConfiguration.getAuthenticateUrl());
+            location.append("?");
+            location.append("scope=openid&response_type=id_token&client_id=");
+            location.append(clientConfiguration.getClientId());
+            location.append("&login_hint=");
+            location.append(loginHint);
+
+            // This should be used to map to some backend thing we can verify ... jwt etc
+            location.append("&state=");
+            final String state = UUID.randomUUID().toString();
+            location.append(state);
+
+            // This should be used to map to some backend thing we can verify ... as once only
+            location.append("&response_mode=form_post&prompt=none&nonce=");
+            location.append(UUID.randomUUID().toString());
+            location.append("&lti_message_hint=");
+            location.append(ltiMessageHint);
+            location.append("&redirect_uri=");
+            location.append(targetLinkUri);
+
+            log.info("OIDC Redirecting to " + location.toString());
+            mav = new ModelAndView(new RedirectView(location.toString()));
+
+        } else {
+            String msg = "unknown issuer [" + iss + "] client [" + clientId + "]";
+            log.error(msg);
+            MappingJackson2JsonView view = new MappingJackson2JsonView();
+            view.setPrettyPrint(true);
+            mav = new ModelAndView(view);
+            mav.addObject("error", msg);
+            mav.addObject("queryParams", queryParams);
+        }
+        return mav;
+
+    }
+}

+ 100 - 0
src/main/java/riomhaire/lti/core/business/commands/LTILaunchCommand.java

@@ -0,0 +1,100 @@
+package riomhaire.lti.core.business.commands;
+
+import com.auth0.jwt.JWT;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.servlet.ModelAndView;
+import riomhaire.lti.core.adapters.token.JWKBasedJwtToMapAdapter;
+import riomhaire.lti.core.model.ClientConfiguration;
+import riomhaire.lti.core.model.LTIMessage;
+import riomhaire.lti.core.model.LTIMessageType;
+import riomhaire.lti.core.model.interfaces.ApplicationRegistry;
+import riomhaire.lti.core.model.interfaces.Command;
+import riomhaire.lti.core.model.interfaces.DecodeException;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Processes lti launch content and create deep link messages - IE claims within a JWT
+ * Delegates to the actual handler
+ */
+@AllArgsConstructor
+@Slf4j
+public class LTILaunchCommand implements Command<ModelAndView, ApplicationRegistry, LTIMessage> {
+    public static final String CLAIM_MESSAGE_TYPE = "https://purl.imsglobal.org/spec/lti/claim/message_type";
+    public static final String LTI_RESOURCE_LINK_REQUEST = "LtiResourceLinkRequest";
+    public static final String LTI_DEEP_LINKING_REQUEST = "LtiDeepLinkingRequest";
+
+    @Override
+    public ModelAndView execute(ApplicationRegistry registry, LTIMessage message) {
+        var idToken = message.getQueryParams().get("id_token");
+        var jwtToken = message.getQueryParams().get("JWT");
+        // Crack open so we can find the config
+        String token = (idToken != null) ? idToken : jwtToken;
+        var jwt = JWT.decode(token);
+        // Lookup client id and issuer
+        var issuer = jwt.getClaim("iss").asString();
+        var clientId = jwt.getClaim("aud").asString();
+
+        var toolRegistration = registry.clientRegistrationResolver().lookupClient(issuer, clientId);
+        var claims = new HashMap<String,Object>();
+        if( toolRegistration.isPresent()) {
+            claims.putAll(decodeToken(token, issuer, clientId, toolRegistration.get()));
+        }
+
+        // OK based off of the message delegate to the right sub-action
+        var messageType = String.valueOf(claims.get(CLAIM_MESSAGE_TYPE));
+        return switch (messageType) {
+            case LTI_RESOURCE_LINK_REQUEST:
+                // Build Message
+                var delegateLaunchMessage = LTIMessage.builder()
+                        .messageType(LTIMessageType.LaunchContent.name())
+                        .metadata(message.getMetadata())
+                        .queryParams(message.getQueryParams())
+                        .claims(claims)
+                        .build();
+                yield registry.commandDispatcher().dispatch(registry, delegateLaunchMessage);
+            case LTI_DEEP_LINKING_REQUEST:
+                // Build Message
+                var delegateDeepLinkMessage = LTIMessage.builder()
+                        .messageType(LTIMessageType.SelectDeepLinkContent.name())
+                        .metadata(message.getMetadata())
+                        .queryParams(message.getQueryParams())
+                        .claims(claims)
+                        .build();
+                yield registry.commandDispatcher().dispatch(registry, delegateDeepLinkMessage);
+            default:
+                throw new IllegalStateException("Unexpected value: " + messageType);
+        };
+    }
+
+    /**
+     * This method using info tool configuration for jwks etc
+     *
+     * @param token            token to decode
+     * @param issuer           who the issuer was
+     * @param clientId         the client id
+     * @param clientConfiguration the tool configuration
+     * @return Model and View to Return the code
+     */
+    private Map<String, Object> decodeToken(String token, String issuer, String clientId, ClientConfiguration clientConfiguration) {
+        var claims = new HashMap<String, Object>();
+
+        if (clientConfiguration != null) {
+            // Validate JWT to verify its by who they say they are
+            var adapter = new JWKBasedJwtToMapAdapter(clientConfiguration.getJwksUrl(), clientConfiguration.isSkipVerification());
+            try {
+                claims.putAll(adapter.decode(token));
+            } catch (DecodeException e) {
+                // OK not valid
+                claims.put("error", "cannot verify token because of: " + e.toString());
+            }
+        } else {
+            claims.put("error", "cannot find client for " + issuer + "  client-id " + clientId);
+        }
+        return claims;
+    }
+
+
+}

+ 33 - 0
src/main/java/riomhaire/lti/core/business/commands/LaunchContentCommand.java

@@ -0,0 +1,33 @@
+package riomhaire.lti.core.business.commands;
+
+import org.springframework.web.servlet.ModelAndView;
+import riomhaire.lti.core.model.LTIMessage;
+import riomhaire.lti.core.model.interfaces.ApplicationRegistry;
+import riomhaire.lti.core.model.interfaces.Command;
+
+import java.util.HashMap;
+import java.util.function.Function;
+
+public class LaunchContentCommand implements Command<ModelAndView, ApplicationRegistry, LTIMessage> {
+
+    @Override
+    public ModelAndView execute(ApplicationRegistry environment, LTIMessage message) {
+        final String prefix = "https://purl.imsglobal.org/spec/lti/claim/";
+        var simplifiedClaims = new HashMap<String, Object>();
+        var mav = new ModelAndView("dump-launch");
+//        var mav = new ModelAndView("redirect:https://www.riomhaire.com");
+
+        // Strip "https://purl.imsglobal.org/spec/lti/claim/" from keys
+        Function<String, String> simplifier = s -> {
+            if (s != null && s.startsWith(prefix)) {
+                return s.substring(prefix.length());
+            }
+            return s;
+        };
+        message.getClaims().forEach((s, o) -> simplifiedClaims.put(simplifier.apply(s), o));
+        mav.addObject("claims", simplifiedClaims);
+        mav.addObject("metadata", message.getMetadata());
+
+        return mav;
+    }
+}

+ 23 - 0
src/main/java/riomhaire/lti/core/infrastructure/api/Endpoint.java

@@ -0,0 +1,23 @@
+package riomhaire.lti.core.infrastructure.api;
+
+import lombok.Getter;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.servlet.ModelAndView;
+import riomhaire.lti.core.model.LTIMessage;
+import riomhaire.lti.core.model.interfaces.ApplicationRegistry;
+import riomhaire.lti.core.model.interfaces.CommandDispatcher;
+
+import javax.annotation.PostConstruct;
+
+@Getter
+public class Endpoint {
+    @Autowired
+    private ApplicationRegistry registry;
+    private CommandDispatcher<ModelAndView, ApplicationRegistry, LTIMessage> dispatcher;
+
+    @PostConstruct
+    protected void initEndpoint() {
+        dispatcher = registry.commandDispatcher();
+    }
+
+}

+ 30 - 0
src/main/java/riomhaire/lti/core/infrastructure/api/tool/LaunchEndpoint.java

@@ -0,0 +1,30 @@
+package riomhaire.lti.core.infrastructure.api.tool;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.ModelAndView;
+import riomhaire.lti.core.infrastructure.api.Endpoint;
+import riomhaire.lti.core.model.LTIMessage;
+import riomhaire.lti.core.model.LTIMessageType;
+
+import java.util.Map;
+
+
+@RestController
+@Slf4j
+public class LaunchEndpoint extends Endpoint {
+
+
+    @RequestMapping(path = "/tool")
+    public ModelAndView tool(@RequestParam Map<String, String> queryParams, @RequestHeader Map<String, Object> headers) {
+        var message = LTIMessage.builder()
+                .messageType(LTIMessageType.ProcessLTIMessage.name())
+                .metadata(headers)
+                .queryParams(queryParams)
+                .build();
+        return getDispatcher().dispatch(getRegistry(), message);
+    }
+}

+ 32 - 0
src/main/java/riomhaire/lti/core/infrastructure/api/tool/OIDCEndpoint.java

@@ -0,0 +1,32 @@
+package riomhaire.lti.core.infrastructure.api.tool;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.ModelAndView;
+import riomhaire.lti.core.infrastructure.api.Endpoint;
+import riomhaire.lti.core.model.LTIMessage;
+import riomhaire.lti.core.model.LTIMessageType;
+
+import java.util.Map;
+
+@RestController
+@Slf4j
+public class OIDCEndpoint extends Endpoint {
+
+
+    @RequestMapping(path = "/oidc")
+    public ModelAndView oidc(@RequestParam Map<String, String> queryParams, @RequestHeader Map<String, Object> headers) {
+
+        var message = LTIMessage.builder()
+                .messageType(LTIMessageType.ProcessOidcRequest.name())
+                .metadata(headers)
+                .queryParams(queryParams)
+                .build();
+        return getDispatcher().dispatch(getRegistry(), message);
+    }
+
+
+}

+ 19 - 0
src/main/java/riomhaire/lti/core/infrastructure/configuration/DecoupledLogicSetup.java

@@ -0,0 +1,19 @@
+package riomhaire.lti.core.infrastructure.configuration;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver;
+
+@Slf4j
+@Configuration
+public class DecoupledLogicSetup {
+    @Bean
+    public SpringResourceTemplateResolver templateResolver() {
+        final SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
+        templateResolver.setCacheable(false);
+        templateResolver.setPrefix("classpath:/templates/");
+        templateResolver.setSuffix(".html");
+        return templateResolver;
+    }
+}

+ 15 - 0
src/main/java/riomhaire/lti/core/infrastructure/facades/clientregistration/ConfigBasedClientRegistrationResolver.java

@@ -0,0 +1,15 @@
+package riomhaire.lti.core.infrastructure.facades.clientregistration;
+
+
+import riomhaire.lti.core.model.ClientConfiguration;
+import riomhaire.lti.core.model.interfaces.ClientRegistrationResolver;
+
+import java.util.Optional;
+
+public class ConfigBasedClientRegistrationResolver implements ClientRegistrationResolver {
+
+    @Override
+    public Optional<ClientConfiguration> lookupClient(String issuer, String clientId) {
+        return Optional.empty();
+    }
+}

+ 34 - 0
src/main/java/riomhaire/lti/core/infrastructure/facades/clientregistration/RedisBasedClientRegistrationResolver.java

@@ -0,0 +1,34 @@
+package riomhaire.lti.core.infrastructure.facades.clientregistration;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.SneakyThrows;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.util.DigestUtils;
+import riomhaire.lti.core.model.ClientConfiguration;
+import riomhaire.lti.core.model.interfaces.ClientRegistrationResolver;
+
+import java.util.Optional;
+
+
+public class RedisBasedClientRegistrationResolver implements ClientRegistrationResolver {
+    final ObjectMapper objectMapper = new ObjectMapper();
+    private final RedisTemplate<String, String> template;
+
+    public RedisBasedClientRegistrationResolver(RedisTemplate<String, String> template) {
+        this.template = template;
+    }
+
+    @SneakyThrows
+    @Override
+    public Optional<ClientConfiguration> lookupClient(String issuer, String clientId) {
+        var hash = DigestUtils.md5DigestAsHex((issuer + clientId).getBytes());
+        var key = String.format("lti-client-registration-%s", hash);
+        var blob = template.opsForValue().get(key);
+
+        if (blob != null) {
+            ClientConfiguration client = objectMapper.readValue(blob, ClientConfiguration.class);
+            return Optional.of(client);
+        }
+        return Optional.empty();
+    }
+}

+ 23 - 0
src/main/java/riomhaire/lti/core/model/ClientConfiguration.java

@@ -0,0 +1,23 @@
+package riomhaire.lti.core.model;
+
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ClientConfiguration {
+    String id;            // Hash of issuer and clientid
+    String name;          // Name of the tool installation
+    String issuer;        // WHo issued the request - usually the LMS
+    String clientId;      // Tool Client ID
+    String jwksUrl;       // Where the public key for the issuer can be found
+    String authenticateUrl;  // The call back authentication url
+    boolean skipVerification;          // If true then skip token verification
+    List<String> deployments; // List of specific deployments of this client
+
+}

+ 14 - 1
src/main/java/riomhaire/lti/core/model/LTIMessage.java

@@ -1,2 +1,15 @@
-package riomhaire.lti.core.model;public class LTIMessage {
+package riomhaire.lti.core.model;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.Map;
+
+@Builder
+@Data
+public class LTIMessage {
+    private String messageType;
+    private Map<String, Object> metadata;
+    private Map<String, String> queryParams;
+    private Map<String, Object> claims;
 }

+ 8 - 0
src/main/java/riomhaire/lti/core/model/LTIMessageType.java

@@ -0,0 +1,8 @@
+package riomhaire.lti.core.model;
+
+public enum LTIMessageType {
+    LaunchContent,
+    ProcessLTIMessage,
+    ProcessOidcRequest,
+    SelectDeepLinkContent
+}

+ 21 - 0
src/main/java/riomhaire/lti/core/model/interfaces/ApplicationRegistry.java

@@ -0,0 +1,21 @@
+package riomhaire.lti.core.model.interfaces;
+
+import org.springframework.web.servlet.ModelAndView;
+import riomhaire.lti.core.model.LTIMessage;
+
+/**
+ * This is the minimal Registry info on the environment. Its the context within which commands run and can get
+ * Access to other components
+ */
+public interface ApplicationRegistry {
+
+    // This is a entity which allows tool to verify token etc is from some reputible source
+    // The default implementation is a caller to redis where we have information on a deployed registered tool
+    // And where to find the JWKS etc
+    ClientRegistrationResolver clientRegistrationResolver();
+
+    // command dispatcher... this is the entity that stores a mapping between a message type and its handler
+    // command. It looks up the message type and delegates to a command to handle it. There is a default command
+    // handler if no command has been registered. At the moment its a 1-1 mapping
+    CommandDispatcher<ModelAndView, ApplicationRegistry, LTIMessage> commandDispatcher();
+}

+ 9 - 0
src/main/java/riomhaire/lti/core/model/interfaces/ClientRegistrationResolver.java

@@ -0,0 +1,9 @@
+package riomhaire.lti.core.model.interfaces;
+
+import riomhaire.lti.core.model.ClientConfiguration;
+
+import java.util.Optional;
+
+public interface ClientRegistrationResolver {
+    Optional<ClientConfiguration> lookupClient(String issuer, String clientId);
+}

+ 12 - 0
src/main/java/riomhaire/lti/core/model/interfaces/Command.java

@@ -0,0 +1,12 @@
+package riomhaire.lti.core.model.interfaces;
+
+/**
+ * Defines  execute command on message P within environment E returning T
+ *
+ * @param <T>
+ * @param <E>
+ * @param <P>
+ */
+public interface Command<T, E, P> {
+    T execute(E environment, P message);
+}

+ 8 - 0
src/main/java/riomhaire/lti/core/model/interfaces/CommandDispatcher.java

@@ -0,0 +1,8 @@
+package riomhaire.lti.core.model.interfaces;
+
+public interface CommandDispatcher<R, E, P> {
+
+    CommandDispatcher<R, E, P> addCommand(String commandName, Command<R, E, P> command);
+
+    R dispatch(E environment, P message);
+}

+ 4 - 0
src/main/java/riomhaire/lti/core/model/interfaces/DecodeException.java

@@ -0,0 +1,4 @@
+package riomhaire.lti.core.model.interfaces;
+
+public class DecodeException extends Exception {
+}

+ 6 - 0
src/main/java/riomhaire/lti/core/model/interfaces/Decoder.java

@@ -0,0 +1,6 @@
+package riomhaire.lti.core.model.interfaces;
+
+@FunctionalInterface
+public interface Decoder<T, P> {
+    T decode(P token) throws DecodeException;
+}

+ 10 - 0
src/main/resources/application.properties

@@ -0,0 +1,10 @@
+spring.thymeleaf.cache=false
+spring.thymeleaf.suffix=.html
+#spring.thymeleaf.prefix=classpath:/templates/
+spring.redis.database=0
+spring.redis.host=localhost
+spring.redis.port=6379
+#spring.redis.password=mypass
+spring.redis.timeout=60000
+
+      

+ 71 - 0
src/main/resources/templates/dump-launch.html

@@ -0,0 +1,71 @@
+<!DOCTYPE HTML>
+<html xmlns:th="https://www.thymeleaf.org">
+<head>
+    <meta charset="utf-8">
+    <meta content="width=device-width, initial-scale=1, shrink-to-fit=yes" name="viewport">
+    <title>Launch Dump</title>
+    <style>
+        th {
+            min-width: 200px;
+            text-align: right;
+            background-color: #394b58;
+            color: white;
+            font-family: Serif;
+            padding-left: 10px;
+            padding-right: 10px;
+            border-radius: 5px;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+            overflow: hidden;
+            vertical-align: top;
+        }
+
+        td {
+            text-align: left;
+            background-color: #d1edfa;
+            color: #394b58;
+            font-family: Serif;
+            padding-left: 10px;
+            padding-right: 10px;
+            border-radius: 5px;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+            overflow: hidden;
+        }
+
+    </style>
+</head>
+
+<body>
+<h3>Meta Data</h3>
+<table>
+    <tr th:each="metadata : ${metadata}">
+        <th th:text="${metadata.key}">keyvalue</th>
+        <td th:text="${metadata.value}">num</td>
+    </tr>
+</table>
+
+<h3>Launch Claims</h3>
+<table title="Launch Claims">
+    <tr th:each="claim : ${claims}">
+        <th th:text="${claim.key}"></th>
+        <td th:if="${claim.value.class.name == 'java.util.ArrayList'}">
+            <div th:each="row : ${claim.value}">
+                <div th:text="${row}"/>
+            </div>
+        </td>
+        <td th:if="${claim.value.class.name == 'org.jose4j.json.JsonUtil$DupeKeyDisallowingLinkedHashMap'}">
+            <table>
+                <tr th:each="objmap : ${claim.value}">
+                    <th th:text="${objmap.key}"></th>
+                    <td th:text="${objmap.value}"></td>
+
+                </tr>
+            </table>
+        </td>
+        <td th:if="${claim.value.class.name == 'java.lang.String'}" th:text="${claim.value}">num</td>
+        <td th:if="${claim.value.class.name == 'java.lang.Long'}" th:text="${claim.value}">num</td>
+    </tr>
+</table>
+</body>
+</html>

+ 12 - 0
src/main/resources/templates/error.html

@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta content="width=device-width, initial-scale=1, shrink-to-fit=yes" name="viewport">
+    <title>Opps - we have an error</title>
+</head>
+
+<body>
+Hmm
+</body>
+</html>

+ 16 - 0
src/main/resources/templates/nop.html

@@ -0,0 +1,16 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta content="width=device-width, initial-scale=1, shrink-to-fit=yes" name="viewport">
+    <title>NOP Page</title>
+    <style>
+
+    </style>
+</head>
+
+<body>
+<h3>This 'No Operation' page means nothing to be done</h3>
+
+</body>
+</html>

+ 8 - 0
src/redis/lti-client-registration-fc478730296ffab709218f28a22f0658.json

@@ -0,0 +1,8 @@
+{
+  "id": "368940666--705430222",
+  "name": "Canvas",
+  "clientId": "32990000000000155",
+  "issuer": "https://canvas.instructure.com",
+  "jwksUrl": "https://hmh.instructure.com/api/lti/security/jwks",
+  "authenticateUrl": "https://hmh.instructure.com/api/lti/authorize"
+}