├── .github └── workflows │ ├── pr.yml │ └── push.yml ├── .gitignore ├── .java-version ├── LICENSE.txt ├── README.md ├── pom.xml └── src ├── main ├── java │ └── uk │ │ └── ac │ │ └── ox │ │ └── ctl │ │ └── lti13 │ │ ├── KeyPairService.java │ │ ├── Lti13Configurer.java │ │ ├── Lti13ConfigurerUtils.java │ │ ├── OAuth2Interceptor.java │ │ ├── SingleKeyPairService.java │ │ ├── TokenRetriever.java │ │ ├── lti │ │ ├── Claims.java │ │ ├── ContextTypes.java │ │ └── Role.java │ │ ├── nrps │ │ ├── Context.java │ │ ├── LtiScopes.java │ │ ├── Member.java │ │ ├── Message.java │ │ ├── NRPSResponse.java │ │ └── NamesRoleService.java │ │ ├── security │ │ └── oauth2 │ │ │ ├── OAuthAuthenticationFailureHandler.java │ │ │ ├── client │ │ │ └── lti │ │ │ │ ├── authentication │ │ │ │ ├── OidcAuthenticationToken.java │ │ │ │ ├── OidcLaunchFlowAuthenticationProvider.java │ │ │ │ ├── OidcLaunchFlowToken.java │ │ │ │ ├── OidcTokenValidator.java │ │ │ │ └── TargetLinkUriAuthenticationSuccessHandler.java │ │ │ │ └── web │ │ │ │ ├── AuthorizationRedirectHandler.java │ │ │ │ ├── HttpSessionOAuth2AuthorizationRequestRepository.java │ │ │ │ ├── InvalidClientRegistrationIdException.java │ │ │ │ ├── InvalidInitiationRequestException.java │ │ │ │ ├── LTIAuthorizationGrantType.java │ │ │ │ ├── OAuth2AuthorizationRequestRedirectFilter.java │ │ │ │ ├── OAuth2LoginAuthenticationFilter.java │ │ │ │ ├── OIDCInitiatingLoginRequestResolver.java │ │ │ │ ├── OIDCInitiationRegistrationResolver.java │ │ │ │ ├── OptimisticAuthorizationRequestRepository.java │ │ │ │ ├── PathOIDCInitiationRegistrationResolver.java │ │ │ │ ├── StateAuthorizationRedirectHandler.java │ │ │ │ ├── StateAuthorizationRequestRepository.java │ │ │ │ └── StateCheckingAuthenticationSuccessHandler.java │ │ │ └── core │ │ │ ├── endpoint │ │ │ ├── OIDCLaunchFlowExchange.java │ │ │ └── OIDCLaunchFlowResponse.java │ │ │ └── user │ │ │ └── LtiOauth2User.java │ │ └── utils │ │ ├── KeyStoreKeyFactory.java │ │ └── StringReader.java └── resources │ └── uk │ └── ac │ └── ox │ └── ctl │ └── lti13 │ ├── step-1-redirect.html │ └── step-3-redirect.html └── test └── java └── uk └── ac └── ox └── ctl └── lti13 ├── config └── Lti13Configuration.java ├── stateful ├── Lti13Step1Test.java └── Lti13Step3Test.java └── stateless ├── Lti13Step1Test.java └── Lti13Step3Test.java /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | # This builds, tests, docs 2 | name: PR Checks 3 | 4 | on: 5 | pull_request: 6 | branches: [ master ] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-java@v4 14 | with: 15 | java-version-file: .java-version 16 | distribution: temurin 17 | cache: maven 18 | - run: mvn -B -Prelease package 19 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | # This builds, tests, docs and then publishes to GitHub Packages. 2 | name: Build and publish 3 | 4 | on: 5 | push: 6 | branches: [ master ] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | packages: write 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-java@v4 17 | with: 18 | java-version-file: .java-version 19 | distribution: temurin 20 | cache: maven 21 | - run: mvn -B -Prelease deploy 22 | env: 23 | GITHUB_TOKEN: ${{ github.token }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | !.mvn/wrapper/maven-wrapper.jar 3 | !**/src/main/** 4 | !**/src/test/** 5 | 6 | ### STS ### 7 | .apt_generated 8 | .classpath 9 | .factorypath 10 | .project 11 | .settings 12 | .springBeans 13 | .sts4-cache 14 | 15 | ### IntelliJ IDEA ### 16 | .idea 17 | *.iws 18 | *.iml 19 | *.ipr 20 | 21 | ### NetBeans ### 22 | /nbproject/private/ 23 | /nbbuild/ 24 | /dist/ 25 | /nbdist/ 26 | /.nb-gradle/ 27 | build/ 28 | 29 | config/application.properties 30 | pom.xml.releaseBackup 31 | release.properties 32 | 33 | # Mac Stuff 34 | .DS_Store 35 | -------------------------------------------------------------------------------- /.java-version: -------------------------------------------------------------------------------- 1 | 17 -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spring Security LTI 1.3 Implementation 2 | 3 | [![Build and publish](https://github.com/oxctl/spring-security-lti13/actions/workflows/push.yml/badge.svg)](https://github.com/oxctl/spring-security-lti13/actions/workflows/push.yml) 4 | 5 | This library adds support to Spring Security to allow LTI 1.3 launches to authenticate a user. This is still a work in progress and will be changing substantially in the near future. 6 | 7 | ## Spring Security OAuth2 8 | 9 | This library uses [Spring Security](https://spring.io/projects/spring-security) and it's OAuth code as its basis. 10 | 11 | ## Using 12 | 13 | This [library](https://search.maven.org/artifact/uk.ac.ox.ctl/spring-security-lti13) is released to maven central and can be added to your maven project with the following project coordinates: 14 | 15 | ```xml 16 | 17 | uk.ac.ox.ctl 18 | spring-security-lti13 19 | 0.3.2 20 | 21 | ``` 22 | 23 | There is a [demo project](https://github.com/oxctl/spring-security-lti13-demo) built using this library that may be helpful in getting started with the project. 24 | 25 | ### Development Builds 26 | 27 | If you want to use the latest (unstable) unreleased version of the library in your project builds are published to GitHub packages. To use this version in your project you need to add the GitHub packages repository to your `pom.xml`: 28 | 29 | ```xml 30 | 31 | 32 | oxctl/spring-security-lti13 33 | https://maven.pkg.github.com/oxctl/spring-security-lti13 34 | 35 | true 36 | 37 | 38 | false 39 | 40 | 41 | 42 | ``` 43 | 44 | then add the `SNAPSHOT` version of the library to your `pom.xml`: 45 | 46 | ```xml 47 | 48 | uk.ac.ox.ctl 49 | spring-security-lti13 50 | 0.2.1-SNAPSHOT 51 | 52 | ``` 53 | 54 | However, this should just be for testing until a new version is released to Maven Central. 55 | 56 | ### Releasing 57 | 58 | The project is deployed to the central repository, once ready to release use the release plugin to tag everything: 59 | 60 | ```bash 61 | mvn -Prelease,sonatype release:clean release:prepare 62 | ``` 63 | 64 | then if that completes successfully a release bundle can be pushed to the staging area of the Sonatype OSS repository with: 65 | 66 | ```bash 67 | mvn -Prelease,sonatype release:perform 68 | ``` 69 | 70 | We don't automatically close the staged artifacts so after checking that the files are ok you should login to the [repository](https://oss.sonatype.org/) and release it. The version in the README.md should also be updated so that people using the project get the latest version and the demo project should be updated to use the latest version. 71 | 72 | ## References 73 | 74 | - Learning Tools Interoperability Core Specification - https://www.imsglobal.org/spec/lti/v1p3 75 | - 1 EdTech Security Framework - https://www.imsglobal.org/spec/lti/v1p3 76 | - OpenID Connect Core - https://openid.net/specs/openid-connect-core-1_0.html 77 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | uk.ac.ox.ctl 6 | spring-security-lti13 7 | 0.3.4-SNAPSHOT 8 | 9 | 17 10 | 17 11 | UTF-8 12 | 6.3.2 13 | 14 | 6.1.12 15 | 16 | 17 | Spring Security LTI 1.3 18 | A LTI 1.3 implementation for Spring Security that builds on the OAuth2 support 19 | https://github.com/oxctl/spring-security-lti13 20 | 21 | 22 | 23 | Apache License, Version 2.0 24 | https://www.apache.org/licenses/LICENSE-2.0.txt 25 | repo 26 | A business-friendly OSS license 27 | 28 | 29 | 30 | 31 | IT Services, University of Oxford 32 | https://www.it.ox.ac.uk/ 33 | 34 | 35 | 36 | 37 | Matthew Buckett 38 | matthew.buckett@it.ox.ac.uk 39 | IT Services, University of Oxford 40 | 41 | 42 | 43 | 44 | scm:git:ssh://git@github.com/oxctl/spring-security-lti13.git 45 | scm:git:ssh://git@github.com/oxctl/spring-security-lti13.git 46 | https://github.com/oxctl/spring-security-lti13 47 | HEAD 48 | 49 | 50 | 51 | 52 | github 53 | GitHub OXCTL Spring Security Packages 54 | https://maven.pkg.github.com/oxctl/spring-security-lti13 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | org.springframework 63 | spring-framework-bom 64 | ${spring.version} 65 | pom 66 | import 67 | 68 | 69 | 70 | org.springframework.security 71 | spring-security-bom 72 | ${spring.security.version} 73 | pom 74 | import 75 | 76 | 77 | 78 | 79 | 80 | 81 | org.springframework 82 | spring-web 83 | 84 | 85 | org.springframework.security 86 | spring-security-config 87 | 88 | 89 | org.springframework.security 90 | spring-security-oauth2-client 91 | 92 | 93 | javax.mail 94 | com.sun.mail 95 | 96 | 97 | 98 | 99 | org.springframework.security 100 | spring-security-oauth2-jose 101 | 102 | 103 | com.fasterxml.jackson.core 104 | jackson-databind 105 | 2.17.0 106 | compile 107 | 108 | 109 | jakarta.activation 110 | jakarta.activation-api 111 | 2.1.0 112 | 113 | 114 | jakarta.platform 115 | jakarta.jakartaee-api 116 | 10.0.0 117 | 118 | 119 | org.slf4j 120 | slf4j-api 121 | 2.0.13 122 | 123 | 124 | com.google.guava 125 | guava 126 | 33.3.0-jre 127 | 128 | 129 | org.junit.jupiter 130 | junit-jupiter-engine 131 | 5.9.2 132 | test 133 | 134 | 135 | org.hamcrest 136 | hamcrest-library 137 | 2.2 138 | test 139 | 140 | 141 | org.springframework.security 142 | spring-security-test 143 | test 144 | 145 | 146 | org.mockito 147 | mockito-core 148 | 5.11.0 149 | test 150 | 151 | 152 | org.slf4j 153 | slf4j-simple 154 | 2.0.13 155 | test 156 | 157 | 158 | org.springframework 159 | spring-webmvc 160 | test 161 | 162 | 163 | 164 | 165 | 166 | 167 | org.apache.maven.plugins 168 | maven-compiler-plugin 169 | 3.13.0 170 | 171 | 172 | org.apache.maven.plugins 173 | maven-clean-plugin 174 | 3.4.0 175 | 176 | 177 | org.apache.maven.plugins 178 | maven-deploy-plugin 179 | 3.1.3 180 | 181 | 182 | org.apache.maven.plugins 183 | maven-install-plugin 184 | 3.1.3 185 | 186 | 187 | org.apache.maven.plugins 188 | maven-jar-plugin 189 | 3.4.2 190 | 191 | 192 | org.apache.maven.plugins 193 | maven-resources-plugin 194 | 3.3.1 195 | 196 | 197 | org.apache.maven.plugins 198 | maven-site-plugin 199 | 3.20.0 200 | 201 | 202 | org.apache.maven.plugins 203 | maven-enforcer-plugin 204 | 3.5.0 205 | 206 | 207 | enforce-maven 208 | 209 | enforce 210 | 211 | 212 | 213 | 214 | [3.5.0,) 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | org.apache.maven.plugins 223 | maven-surefire-plugin 224 | 3.4.0 225 | 226 | 227 | org.apache.maven.plugins 228 | maven-release-plugin 229 | 3.1.1 230 | 231 | true 232 | false 233 | release 234 | @{project.version} 235 | deploy 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | release 244 | 245 | 246 | 247 | 248 | org.apache.maven.plugins 249 | maven-source-plugin 250 | 3.3.1 251 | 252 | 253 | attach-sources 254 | 255 | jar-no-fork 256 | 257 | 258 | 259 | 260 | 261 | org.apache.maven.plugins 262 | maven-javadoc-plugin 263 | 3.8.0 264 | 265 | 266 | attach-javadocs 267 | 268 | jar 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | sonatype 278 | 279 | 280 | 281 | org.apache.maven.plugins 282 | maven-gpg-plugin 283 | 3.0.1 284 | 285 | 286 | sign-artifacts 287 | verify 288 | 289 | sign 290 | 291 | 292 | 293 | 294 | 295 | org.sonatype.plugins 296 | nexus-staging-maven-plugin 297 | 1.6.13 298 | true 299 | 300 | ossrh 301 | https://oss.sonatype.org/ 302 | true 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/KeyPairService.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13; 2 | 3 | import java.security.KeyPair; 4 | 5 | /** 6 | * This maps a client registration ID to a keypair to be used for signing. 7 | */ 8 | public interface KeyPairService { 9 | 10 | /** 11 | * Gets the key pair to be used with a particular client. 12 | * @param clientRegistrationId The client's registration ID. 13 | * @return The KeyPair to use. 14 | */ 15 | KeyPair getKeyPair(String clientRegistrationId); 16 | 17 | /** 18 | * Gets the key ID to be used with a particular client. 19 | * @param clientRegistration Thie client's registration ID. 20 | * @return The Key ID to use. 21 | */ 22 | String getKeyId(String clientRegistration); 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/Lti13Configurer.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13; 2 | 3 | import org.springframework.context.ApplicationEventPublisher; 4 | import org.springframework.security.authentication.DefaultAuthenticationEventPublisher; 5 | import org.springframework.security.authentication.ProviderManager; 6 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 7 | import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; 8 | import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; 9 | import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; 10 | import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; 11 | import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; 12 | import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository; 13 | import org.springframework.security.web.authentication.logout.LogoutFilter; 14 | import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter; 15 | import org.springframework.security.web.context.SecurityContextRepository; 16 | import org.springframework.security.web.context.SecurityContextRepository; 17 | import uk.ac.ox.ctl.lti13.security.oauth2.OAuthAuthenticationFailureHandler; 18 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.authentication.OidcLaunchFlowAuthenticationProvider; 19 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.authentication.TargetLinkUriAuthenticationSuccessHandler; 20 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web.OAuth2AuthorizationRequestRedirectFilter; 21 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web.OAuth2LoginAuthenticationFilter; 22 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web.OIDCInitiatingLoginRequestResolver; 23 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web.OptimisticAuthorizationRequestRepository; 24 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web.StateAuthorizationRequestRepository; 25 | 26 | import java.time.Duration; 27 | import java.util.Collections; 28 | 29 | 30 | /** 31 | * 32 | *

Shared Objects Used

33 | * 34 | * The following shared objects are used: 35 | * 36 | * 39 | */ 40 | public class Lti13Configurer extends AbstractHttpConfigurer { 41 | 42 | protected String ltiPath = "/lti"; 43 | protected String loginPath = "/login"; 44 | protected String loginInitiationPath = "/login_initiation"; 45 | protected ApplicationEventPublisher applicationEventPublisher; 46 | protected GrantedAuthoritiesMapper grantedAuthoritiesMapper; 47 | protected boolean limitIpAddresses; 48 | protected SecurityContextRepository securityContextRepository; 49 | 50 | 51 | public Lti13Configurer ltiPath(String ltiPath) { 52 | this.ltiPath = ltiPath; 53 | return this; 54 | } 55 | 56 | public Lti13Configurer loginPath(String loginPath) { 57 | this.loginPath = loginPath; 58 | return this; 59 | } 60 | 61 | public Lti13Configurer loginInitiationPath(String loginInitiationPath) { 62 | this.loginInitiationPath = loginInitiationPath; 63 | return this; 64 | } 65 | 66 | public Lti13Configurer applicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { 67 | this.applicationEventPublisher = applicationEventPublisher; 68 | return this; 69 | } 70 | 71 | public Lti13Configurer grantedAuthoritiesMapper(GrantedAuthoritiesMapper grantedAuthoritiesMapper) { 72 | this.grantedAuthoritiesMapper = grantedAuthoritiesMapper; 73 | return this; 74 | } 75 | 76 | /** 77 | * This security context repository to persist the authentication in. This is useful if you want to use 78 | * HTTP sessions for authentication. 79 | */ 80 | public Lti13Configurer setSecurityContextRepository(SecurityContextRepository securityContextRepository) { 81 | this.securityContextRepository = securityContextRepository; 82 | return this; 83 | } 84 | 85 | /** 86 | * Using this may cause problems for users who are behind a proxy or NAT setup that uses different IP addresses 87 | * for different requests, even if they are close together in time. 88 | * 89 | * @param limitIpAddresses if true then ensure that all the OAuth requests for a LTI launch come from the same IP 90 | */ 91 | public Lti13Configurer limitIpAddresses(boolean limitIpAddresses) { 92 | this.limitIpAddresses = limitIpAddresses; 93 | return this; 94 | } 95 | 96 | @SuppressWarnings("unchecked") 97 | @Override 98 | public void init(HttpSecurity http) { 99 | // Allow LTI launches to bypass CSRF protection 100 | CsrfConfigurer configurer = http.getConfigurer(CsrfConfigurer.class); 101 | if (configurer != null) { 102 | // I'm not sure about this. 103 | configurer.ignoringRequestMatchers(ltiPath + "/**"); 104 | } 105 | // In the future we should use CSP to limit the domains that can embed this tool 106 | HeadersConfigurer headersConfigurer = http.getConfigurer(HeadersConfigurer.class); 107 | if (headersConfigurer != null) { 108 | headersConfigurer.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable); 109 | } 110 | } 111 | 112 | @Override 113 | public void configure(HttpSecurity http) { 114 | ClientRegistrationRepository clientRegistrationRepository = Lti13ConfigurerUtils.getClientRegistrationRepository(http); 115 | 116 | OidcLaunchFlowAuthenticationProvider oidcLaunchFlowAuthenticationProvider = configureAuthenticationProvider(http); 117 | OptimisticAuthorizationRequestRepository authorizationRequestRepository = configureRequestRepository(); 118 | // This handles step 1 of the IMS SEC 119 | // https://www.imsglobal.org/spec/security/v1p0/#step-1-third-party-initiated-login 120 | http.addFilterAfter(configureInitiationFilter(clientRegistrationRepository, authorizationRequestRepository), LogoutFilter.class); 121 | // This handles step 3 of the IMS SEC 122 | // https://www.imsglobal.org/spec/security/v1p0/#step-3-authentication-response 123 | http.addFilterAfter(configureLoginFilter(clientRegistrationRepository, oidcLaunchFlowAuthenticationProvider, authorizationRequestRepository), AbstractPreAuthenticatedProcessingFilter.class); 124 | } 125 | 126 | protected OptimisticAuthorizationRequestRepository configureRequestRepository() { 127 | HttpSessionOAuth2AuthorizationRequestRepository sessionRepository = new HttpSessionOAuth2AuthorizationRequestRepository(); 128 | StateAuthorizationRequestRepository stateRepository = new StateAuthorizationRequestRepository(Duration.ofMinutes(1)); 129 | stateRepository.setLimitIpAddress(limitIpAddresses); 130 | return new OptimisticAuthorizationRequestRepository( sessionRepository, stateRepository ); 131 | } 132 | 133 | protected OidcLaunchFlowAuthenticationProvider configureAuthenticationProvider(HttpSecurity http) { 134 | OidcLaunchFlowAuthenticationProvider oidcLaunchFlowAuthenticationProvider = new OidcLaunchFlowAuthenticationProvider(); 135 | 136 | http.authenticationProvider(oidcLaunchFlowAuthenticationProvider); 137 | if (grantedAuthoritiesMapper != null) { 138 | oidcLaunchFlowAuthenticationProvider.setAuthoritiesMapper(grantedAuthoritiesMapper); 139 | } 140 | return oidcLaunchFlowAuthenticationProvider; 141 | } 142 | 143 | protected OAuth2AuthorizationRequestRedirectFilter configureInitiationFilter(ClientRegistrationRepository clientRegistrationRepository, OptimisticAuthorizationRequestRepository authorizationRequestRepository) { 144 | OIDCInitiatingLoginRequestResolver resolver = new OIDCInitiatingLoginRequestResolver(clientRegistrationRepository, ltiPath+ loginInitiationPath); 145 | OAuth2AuthorizationRequestRedirectFilter filter = new OAuth2AuthorizationRequestRedirectFilter(resolver); 146 | filter.setAuthorizationRequestRepository(authorizationRequestRepository); 147 | return filter; 148 | } 149 | 150 | protected OAuth2LoginAuthenticationFilter configureLoginFilter(ClientRegistrationRepository clientRegistrationRepository, OidcLaunchFlowAuthenticationProvider oidcLaunchFlowAuthenticationProvider, OptimisticAuthorizationRequestRepository authorizationRequestRepository) { 151 | // This filter handles the actual authentication and behaviour of errors 152 | OAuth2LoginAuthenticationFilter loginFilter = new OAuth2LoginAuthenticationFilter(clientRegistrationRepository, ltiPath+ loginPath); 153 | // This is to find the URL that we should redirect the user to. 154 | TargetLinkUriAuthenticationSuccessHandler successHandler = new TargetLinkUriAuthenticationSuccessHandler(authorizationRequestRepository); 155 | loginFilter.setAuthenticationSuccessHandler(successHandler); 156 | // This is just so that you can get better error messages when something goes wrong. 157 | OAuthAuthenticationFailureHandler failureHandler = new OAuthAuthenticationFailureHandler(); 158 | loginFilter.setAuthenticationFailureHandler(failureHandler); 159 | loginFilter.setAuthorizationRequestRepository(authorizationRequestRepository); 160 | ProviderManager authenticationManager = new ProviderManager(Collections.singletonList(oidcLaunchFlowAuthenticationProvider)); 161 | if (applicationEventPublisher != null) { 162 | authenticationManager.setAuthenticationEventPublisher(new DefaultAuthenticationEventPublisher(applicationEventPublisher)); 163 | } 164 | if (securityContextRepository != null) { 165 | loginFilter.setSecurityContextRepository(securityContextRepository); 166 | } 167 | loginFilter.setAuthenticationManager(authenticationManager); 168 | return loginFilter; 169 | } 170 | 171 | } 172 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/Lti13ConfigurerUtils.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13; 2 | 3 | import org.springframework.context.ApplicationContext; 4 | import org.springframework.security.config.annotation.web.HttpSecurityBuilder; 5 | import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; 6 | 7 | class Lti13ConfigurerUtils { 8 | 9 | static > ClientRegistrationRepository getClientRegistrationRepository(B builder) { 10 | ClientRegistrationRepository clientRegistrationRepository = builder.getSharedObject(ClientRegistrationRepository.class); 11 | if (clientRegistrationRepository == null) { 12 | clientRegistrationRepository = getClientRegistrationRepositoryBean(builder); 13 | builder.setSharedObject(ClientRegistrationRepository.class, clientRegistrationRepository); 14 | } 15 | return clientRegistrationRepository; 16 | } 17 | 18 | private static > ClientRegistrationRepository getClientRegistrationRepositoryBean(B builder) { 19 | return builder.getSharedObject(ApplicationContext.class).getBean(ClientRegistrationRepository.class); 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/OAuth2Interceptor.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13; 2 | 3 | import org.springframework.http.HttpRequest; 4 | import org.springframework.http.client.ClientHttpRequestExecution; 5 | import org.springframework.http.client.ClientHttpRequestInterceptor; 6 | import org.springframework.http.client.ClientHttpResponse; 7 | import org.springframework.security.oauth2.core.OAuth2AccessToken; 8 | 9 | import java.io.IOException; 10 | import java.time.Instant; 11 | 12 | public class OAuth2Interceptor implements ClientHttpRequestInterceptor { 13 | 14 | private OAuth2AccessToken accessToken; 15 | 16 | public OAuth2Interceptor(OAuth2AccessToken accessToken) { 17 | this.accessToken = accessToken; 18 | } 19 | 20 | public String getAccessTokenValue() { 21 | return accessToken.getTokenValue(); 22 | } 23 | 24 | public boolean isValid() { 25 | return accessToken.getExpiresAt() != null && Instant.now().isBefore(accessToken.getExpiresAt()); 26 | } 27 | 28 | @Override 29 | public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { 30 | String accessToken = getAccessTokenValue(); 31 | request.getHeaders().setBearerAuth(accessToken); 32 | return execution.execute(request, body); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/SingleKeyPairService.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13; 2 | 3 | import java.security.KeyPair; 4 | 5 | /** 6 | * Just uses the same keypair for all operations. 7 | */ 8 | public class SingleKeyPairService implements KeyPairService { 9 | 10 | private final KeyPair keyPair; 11 | 12 | private final String keyId; 13 | 14 | public SingleKeyPairService(KeyPair keyPair, String keyId) { 15 | this.keyPair = keyPair; 16 | this.keyId = keyId; 17 | } 18 | 19 | @Override 20 | public KeyPair getKeyPair(String clientRegistrationId) { 21 | return keyPair; 22 | } 23 | 24 | @Override 25 | public String getKeyId(String clientRegistration) { 26 | return keyId; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/TokenRetriever.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13; 2 | 3 | import com.nimbusds.jose.JOSEException; 4 | import com.nimbusds.jose.JOSEObjectType; 5 | import com.nimbusds.jose.JWSAlgorithm; 6 | import com.nimbusds.jose.JWSHeader; 7 | import com.nimbusds.jose.crypto.RSASSASigner; 8 | import com.nimbusds.jwt.JWTClaimsSet; 9 | import com.nimbusds.jwt.SignedJWT; 10 | import org.slf4j.Logger; 11 | import org.slf4j.LoggerFactory; 12 | import org.springframework.http.*; 13 | import org.springframework.http.converter.FormHttpMessageConverter; 14 | import org.springframework.http.converter.HttpMessageConverter; 15 | import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler; 16 | import org.springframework.security.oauth2.client.registration.ClientRegistration; 17 | import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; 18 | import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; 19 | import org.springframework.stereotype.Component; 20 | import org.springframework.util.LinkedMultiValueMap; 21 | import org.springframework.util.MultiValueMap; 22 | import org.springframework.web.client.RestTemplate; 23 | 24 | import java.net.URI; 25 | import java.security.KeyPair; 26 | import java.time.Instant; 27 | import java.util.*; 28 | 29 | /** 30 | * This gets a token to use for LTI Services. 31 | * It uses the public/private key associated with a client registration to sign a JWT which is then used to obtain 32 | * an access token. 33 | * 34 | * @see https://www.imsglobal.org/spec/lti/v1p3/#token-endpoint-claim-and-services 35 | * @see https://www.imsglobal.org/spec/security/v1p0/#using-json-web-tokens-with-oauth-2-0-client-credentials-grant 36 | */ 37 | public class TokenRetriever { 38 | 39 | private final Logger log = LoggerFactory.getLogger(TokenRetriever.class); 40 | 41 | // Lifetime of our JWT in seconds 42 | private int jwtLifetime = 60; 43 | 44 | private final KeyPairService keyPairService; 45 | private final RestTemplate restTemplate; 46 | 47 | public TokenRetriever(KeyPairService keyPairService) { 48 | this.keyPairService = keyPairService; 49 | restTemplate = new RestTemplate(Arrays.asList( 50 | new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter())); 51 | restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler()); 52 | } 53 | 54 | public void addHttpMessageConverter(HttpMessageConverter converter) { 55 | Objects.requireNonNull(converter, "You must supply a converter."); 56 | this.restTemplate.getMessageConverters().add(converter); 57 | } 58 | 59 | public void setJwtLifetime(int jwtLifetime) { 60 | this.jwtLifetime = jwtLifetime; 61 | } 62 | 63 | public OAuth2AccessTokenResponse getToken(ClientRegistration clientRegistration, String... scopes) throws JOSEException { 64 | if (scopes.length == 0) { 65 | throw new IllegalArgumentException("You must supply some scopes to request."); 66 | } 67 | Objects.requireNonNull(clientRegistration, "You must supply a clientRegistration."); 68 | 69 | SignedJWT signedJWT = createJWT(clientRegistration); 70 | MultiValueMap formData = buildFormData(signedJWT, scopes); 71 | // We are using RestTemplate here as that's what the existing OAuth2 code in Spring uses at the moment. 72 | HttpHeaders headers = new HttpHeaders(); 73 | headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); 74 | RequestEntity> requestEntity = new RequestEntity<>( 75 | formData, headers, HttpMethod.POST, URI.create(clientRegistration.getProviderDetails().getTokenUri()) 76 | ); 77 | ResponseEntity exchange = restTemplate.exchange(requestEntity, OAuth2AccessTokenResponse.class); 78 | return exchange.getBody(); 79 | } 80 | 81 | private SignedJWT createJWT(ClientRegistration clientRegistration) throws JOSEException { 82 | KeyPair keyPair = keyPairService.getKeyPair(clientRegistration.getRegistrationId()); 83 | if (keyPair == null) { 84 | throw new NullPointerException( 85 | "Failed to get keypair for client registration: "+ clientRegistration.getRegistrationId() 86 | ); 87 | } 88 | String keyId = keyPairService.getKeyId(clientRegistration.getRegistrationId()); 89 | 90 | RSASSASigner signer = new RSASSASigner(keyPair.getPrivate()); 91 | 92 | JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() 93 | // Both must be set to client ID according to spec. 94 | .issuer(clientRegistration.getClientId()) 95 | .subject(clientRegistration.getClientId()) 96 | .audience(clientRegistration.getProviderDetails().getTokenUri()) 97 | .issueTime(Date.from(Instant.now())) 98 | .jwtID(UUID.randomUUID().toString()) 99 | // 60 Seconds 100 | .expirationTime(Date.from(Instant.now().plusSeconds(jwtLifetime))) 101 | .build(); 102 | 103 | // We don't have to include a key ID, however if we don't then when you use a JWK file the consuming application 104 | // won't know which key to use to verify the signature 105 | final JWSHeader.Builder builder = new JWSHeader.Builder(JWSAlgorithm.RS256); 106 | builder.type(JOSEObjectType.JWT); 107 | if (keyId != null) { 108 | builder.keyID(keyId); 109 | } 110 | JWSHeader jwt = builder.build(); 111 | SignedJWT signedJWT = new SignedJWT(jwt, claimsSet); 112 | signedJWT.sign(signer); 113 | 114 | if (log.isDebugEnabled()) { 115 | log.debug("Created signed token: {}", signedJWT.serialize()); 116 | } 117 | 118 | return signedJWT; 119 | } 120 | 121 | private MultiValueMap buildFormData(SignedJWT signedJWT, String[] scopes) { 122 | MultiValueMap formData = new LinkedMultiValueMap<>(); 123 | formData.add("grant_type", "client_credentials"); 124 | formData.add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"); 125 | formData.add("scope", String.join(" ", scopes)); 126 | formData.add("client_assertion", signedJWT.serialize()); 127 | return formData; 128 | } 129 | 130 | } 131 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/lti/Claims.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.lti; 2 | 3 | public class Claims { 4 | 5 | public static final String MESSAGE_TYPE = "https://purl.imsglobal.org/spec/lti/claim/message_type"; 6 | public static final String LTI_VERSION = "https://purl.imsglobal.org/spec/lti/claim/version"; 7 | public static final String LTI_DEPLOYMENT_ID = "https://purl.imsglobal.org/spec/lti/claim/deployment_id"; 8 | public static final String TARGET_LINK_URI = "https://purl.imsglobal.org/spec/lti/claim/target_link_uri"; 9 | public static final String RESOURCE_LINK = "https://purl.imsglobal.org/spec/lti/claim/resource_link"; 10 | 11 | public static final String ROLES = "https://purl.imsglobal.org/spec/lti/claim/roles"; 12 | public static final String LIS = "https://purl.imsglobal.org/spec/lti/claim/lis"; 13 | 14 | public static final String CONTEXT = "https://purl.imsglobal.org/spec/lti/claim/context"; 15 | public static final String PLATFORM_INSTANCE = "https://purl.imsglobal.org/spec/lti/claim/tool_platform"; 16 | 17 | public static final String LAUNCH_PRESENTATION = "https://purl.imsglobal.org/spec/lti/claim/launch_presentation"; 18 | 19 | public static final String CUSTOM = "https://purl.imsglobal.org/spec/lti/claim/custom"; 20 | 21 | public static final String DEEP_LINKING_SETTINGS = "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"; 22 | public static final String CONTENT_ITEMS = "https://purl.imsglobal.org/spec/lti-dl/claim/content_items"; 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/lti/ContextTypes.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.lti; 2 | 3 | /** 4 | * @see https://www.imsglobal.org/spec/lti/v1p3/#context-type-vocabulary 5 | */ 6 | public class ContextTypes { 7 | 8 | public static final String COURSE_TEMPLATE = "http://purl.imsglobal.org/vocab/lis/v2/course#CourseTemplate"; 9 | public static final String COURSE_TEMPLATE_SHORT = "CourseTemplate"; 10 | public static final String COURSE_TEMPLATE_URN = "urn:lti:context-type:ims/lis/CourseTemplate"; 11 | 12 | public static final String COURSE_OFFERING = "http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering"; 13 | public static final String COURSE_OFFERING_SHORT = "CourseOffering"; 14 | public static final String COURSE_OFFERING_URN = "urn:lti:context-type:ims/lis/CourseOffering"; 15 | 16 | public static final String COURSE_SECTION = "http://purl.imsglobal.org/vocab/lis/v2/course#CourseSection"; 17 | public static final String COURSE_SECTION_SHORT = "CourseSection"; 18 | public static final String COURSE_SECTION_URN = "urn:lti:context-type:ims/lis/CourseSection"; 19 | 20 | public static final String GROUP = "http://purl.imsglobal.org/vocab/lis/v2/course#Group"; 21 | public static final String GROUP_SHORT = "Group"; 22 | public static final String GROUP_URN = "urn:lti:context-type:ims/lis/Group"; 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/lti/Role.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.lti; 2 | 3 | /** 4 | * @see https://www.imsglobal.org/spec/lti/v1p3/#role-vocabularies 5 | */ 6 | public class Role { 7 | 8 | public static class System { 9 | 10 | // Core system roles 11 | public static final String ADMINISTRATOR = "http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator"; 12 | public static final String NONE = "http://purl.imsglobal.org/vocab/lis/v2/system/person#None"; 13 | 14 | // Non-core system roles 15 | public static final String ACCOUNT_ADMIN = "http://purl.imsglobal.org/vocab/lis/v2/system/person#AccountAdmin"; 16 | public static final String CREATOR = "http://purl.imsglobal.org/vocab/lis/v2/system/person#Creator"; 17 | public static final String SYS_ADMIN = "http://purl.imsglobal.org/vocab/lis/v2/system/person#SysAdmin"; 18 | public static final String SYS_SUPPORT = "http://purl.imsglobal.org/vocab/lis/v2/system/person#SysSupport"; 19 | public static final String USER = "http://purl.imsglobal.org/vocab/lis/v2/system/person#User"; 20 | } 21 | 22 | public static class Institution { 23 | 24 | // Core institution roles 25 | public static final String ADMINISTRATOR = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator"; 26 | public static final String FACULTY = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Faculty"; 27 | public static final String GUEST = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Guest"; 28 | public static final String NONE = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#None"; 29 | public static final String OTHER = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Other"; 30 | public static final String STAFF = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Staff"; 31 | public static final String STUDENT = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student"; 32 | 33 | // Non‑core institution roles 34 | public static final String ALUMNI = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Alumni"; 35 | public static final String INSTRUCTOR = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor"; 36 | public static final String LEARNER = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Learner"; 37 | public static final String MEMBER = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Member"; 38 | public static final String MENTOR = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Mentor"; 39 | public static final String OBSERVER = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Observer"; 40 | public static final String PROSPECTIVE_STUDENT = "http://purl.imsglobal.org/vocab/lis/v2/institution/person#ProspectiveStudent"; 41 | } 42 | 43 | public static class Context { 44 | 45 | // Core context roles 46 | public static final String ADMINISTRATOR = "http://purl.imsglobal.org/vocab/lis/v2/membership#Administrator"; 47 | public static final String CONTENT_DEVELOPER = "http://purl.imsglobal.org/vocab/lis/v2/membership#ContentDeveloper"; 48 | public static final String INSTRUCTOR = "http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor"; 49 | public static final String LEARNER = "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"; 50 | public static final String MENTOR = "http://purl.imsglobal.org/vocab/lis/v2/membership#Mentor"; 51 | 52 | // Non‑core context roles 53 | public static final String MANAGER = "http://purl.imsglobal.org/vocab/lis/v2/membership#Manager"; 54 | public static final String MEMBER = "http://purl.imsglobal.org/vocab/lis/v2/membership#Member"; 55 | public static final String OFFICER = "http://purl.imsglobal.org/vocab/lis/v2/membership#Officer"; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/nrps/Context.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.nrps; 2 | 3 | public class Context { 4 | 5 | private String id; 6 | private String label; 7 | private String title; 8 | 9 | public String getId() { 10 | return id; 11 | } 12 | 13 | public void setId(String id) { 14 | this.id = id; 15 | } 16 | 17 | public String getLabel() { 18 | return label; 19 | } 20 | 21 | public void setLabel(String label) { 22 | this.label = label; 23 | } 24 | 25 | public String getTitle() { 26 | return title; 27 | } 28 | 29 | public void setTitle(String title) { 30 | this.title = title; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/nrps/LtiScopes.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.nrps; 2 | 3 | public class LtiScopes { 4 | 5 | public static final String LTI_NRPS_SCOPE = "https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly"; 6 | 7 | public static final String LTI_NRPS_CLAIM = "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice"; 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/nrps/Member.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.nrps; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | 5 | import java.util.List; 6 | 7 | public class Member { 8 | 9 | private String status; 10 | private String name; 11 | // URL of Avatar 12 | private String picture; 13 | @JsonProperty("given_name") 14 | private String givenName; 15 | @JsonProperty("family_name") 16 | private String familyName; 17 | private String email; 18 | @JsonProperty("lis_person_sourcedid") 19 | private String lisPersonSourcedid; 20 | @JsonProperty("user_id") 21 | private String userId; 22 | private List roles; 23 | 24 | private List message; 25 | 26 | public String getStatus() { 27 | return status; 28 | } 29 | 30 | public void setStatus(String status) { 31 | this.status = status; 32 | } 33 | 34 | public String getName() { 35 | return name; 36 | } 37 | 38 | public void setName(String name) { 39 | this.name = name; 40 | } 41 | 42 | public String getPicture() { 43 | return picture; 44 | } 45 | 46 | public void setPicture(String picture) { 47 | this.picture = picture; 48 | } 49 | 50 | public String getGivenName() { 51 | return givenName; 52 | } 53 | 54 | public void setGivenName(String givenName) { 55 | this.givenName = givenName; 56 | } 57 | 58 | public String getFamilyName() { 59 | return familyName; 60 | } 61 | 62 | public void setFamilyName(String familyName) { 63 | this.familyName = familyName; 64 | } 65 | 66 | public String getEmail() { 67 | return email; 68 | } 69 | 70 | public void setEmail(String email) { 71 | this.email = email; 72 | } 73 | 74 | public String getLisPersonSourcedid() { 75 | return lisPersonSourcedid; 76 | } 77 | 78 | public void setLisPersonSourcedid(String lisPersonSourcedid) { 79 | this.lisPersonSourcedid = lisPersonSourcedid; 80 | } 81 | 82 | public String getUserId() { 83 | return userId; 84 | } 85 | 86 | public void setUserId(String userId) { 87 | this.userId = userId; 88 | } 89 | 90 | public List getRoles() { 91 | return roles; 92 | } 93 | 94 | public void setRoles(List roles) { 95 | this.roles = roles; 96 | } 97 | 98 | public List getMessage() { 99 | return message; 100 | } 101 | 102 | public void setMessage(List message) { 103 | this.message = message; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/nrps/Message.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.nrps; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import uk.ac.ox.ctl.lti13.lti.Claims; 5 | 6 | import java.util.Map; 7 | 8 | /** 9 | * This is the additional message that comes back you pass through a resource link ID. 10 | */ 11 | public class Message { 12 | 13 | private String locale; 14 | 15 | @JsonProperty(Claims.CUSTOM) 16 | private Map custom; 17 | 18 | @JsonProperty(Claims.MESSAGE_TYPE) 19 | private String messageType; 20 | 21 | @JsonProperty("https://www.instructure.com/canvas_user_id") 22 | private Integer canvasUserId; 23 | 24 | @JsonProperty("https://www.instructure.com/canvas_user_login_id") 25 | private String canvasLoginId; 26 | 27 | public String getLocale() { 28 | return locale; 29 | } 30 | 31 | public void setLocale(String locale) { 32 | this.locale = locale; 33 | } 34 | 35 | public Map getCustom() { 36 | return custom; 37 | } 38 | 39 | public void setCustom(Map custom) { 40 | this.custom = custom; 41 | } 42 | 43 | public String getMessageType() { 44 | return messageType; 45 | } 46 | 47 | public void setMessageType(String messageType) { 48 | this.messageType = messageType; 49 | } 50 | 51 | public Integer getCanvasUserId() { 52 | return canvasUserId; 53 | } 54 | 55 | public void setCanvasUserId(Integer canvasUserId) { 56 | this.canvasUserId = canvasUserId; 57 | } 58 | 59 | public String getCanvasLoginId() { 60 | return canvasLoginId; 61 | } 62 | 63 | public void setCanvasLoginId(String canvasLoginId) { 64 | this.canvasLoginId = canvasLoginId; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/nrps/NRPSResponse.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.nrps; 2 | 3 | import java.util.List; 4 | 5 | /** 6 | * A Response back from the Names and Roles Provisioning Service. 7 | * I couldn't find a formal definition of this and it looks like there isn't one so it's modeled on examples from Canvas. 8 | * 9 | * @see https://www.imsglobal.org/spec/lti-nrps/v2p0#sharing-of-personal-data 10 | */ 11 | public class NRPSResponse { 12 | 13 | private String id; 14 | private Context context; 15 | private List members; 16 | 17 | public String getId() { 18 | return id; 19 | } 20 | 21 | public void setId(String id) { 22 | this.id = id; 23 | } 24 | 25 | public Context getContext() { 26 | return context; 27 | } 28 | 29 | public void setContext(Context context) { 30 | this.context = context; 31 | } 32 | 33 | public List getMembers() { 34 | return members; 35 | } 36 | 37 | public void setMembers(List members) { 38 | this.members = members; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/nrps/NamesRoleService.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.nrps; 2 | 3 | import com.nimbusds.jose.JOSEException; 4 | import net.minidev.json.JSONObject; 5 | import org.springframework.security.oauth2.client.registration.ClientRegistration; 6 | import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; 7 | import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; 8 | import org.springframework.security.oauth2.core.oidc.user.OidcUser; 9 | import org.springframework.web.client.RestTemplate; 10 | import uk.ac.ox.ctl.lti13.OAuth2Interceptor; 11 | import uk.ac.ox.ctl.lti13.TokenRetriever; 12 | import uk.ac.ox.ctl.lti13.lti.Claims; 13 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.authentication.OidcLaunchFlowToken; 14 | 15 | import java.io.UnsupportedEncodingException; 16 | import java.net.URLEncoder; 17 | import java.util.Collections; 18 | 19 | public class NamesRoleService { 20 | 21 | private final ClientRegistrationRepository clientRegistrationRepository; 22 | private final TokenRetriever tokenRetriever; 23 | 24 | public NamesRoleService(ClientRegistrationRepository clientRegistrationRepository, TokenRetriever tokenRetriever) { 25 | this.clientRegistrationRepository = clientRegistrationRepository; 26 | this.tokenRetriever = tokenRetriever; 27 | } 28 | 29 | public NRPSResponse getMembers(OidcLaunchFlowToken oAuth2AuthenticationToken, boolean includeResourceLink) { 30 | OidcUser principal = oAuth2AuthenticationToken.getPrincipal(); 31 | if (principal != null) { 32 | Object o = principal.getClaims().get(LtiScopes.LTI_NRPS_CLAIM); 33 | if (o instanceof JSONObject) { 34 | JSONObject json = (JSONObject)o; 35 | String contextMembershipsUrl = json.getAsString("context_memberships_url"); 36 | if (contextMembershipsUrl != null && !contextMembershipsUrl.isEmpty()) { 37 | // Got a URL to go to. 38 | Object r = principal.getClaims().get(Claims.RESOURCE_LINK); 39 | String resourceLinkId = null; 40 | if (includeResourceLink && r instanceof JSONObject) { 41 | JSONObject resourceJson = (JSONObject) r; 42 | resourceLinkId = resourceJson.getAsString("id"); 43 | } 44 | return loadMembers(contextMembershipsUrl, resourceLinkId, oAuth2AuthenticationToken.getClientRegistration().getRegistrationId()); 45 | } 46 | } 47 | } 48 | return null; 49 | } 50 | 51 | private NRPSResponse loadMembers(String contextMembershipsUrl, String resourceLinkId, String clientRegistrationId) { 52 | 53 | ClientRegistration clientRegistration = clientRegistrationRepository.findByRegistrationId(clientRegistrationId); 54 | if (clientRegistration == null) { 55 | throw new IllegalStateException("Failed to find client registration for: "+ clientRegistrationId); 56 | } 57 | try { 58 | OAuth2AccessTokenResponse token = tokenRetriever.getToken(clientRegistration, LtiScopes.LTI_NRPS_SCOPE); 59 | 60 | String url = contextMembershipsUrl; 61 | if (resourceLinkId != null) { 62 | url = url + "?rlid="+ URLEncoder.encode(resourceLinkId, "UTF-8"); 63 | } 64 | RestTemplate client = new RestTemplate(); 65 | client.setInterceptors(Collections.singletonList(new OAuth2Interceptor(token.getAccessToken()))); 66 | // TODO Needs to set accept header to: application/vnd.ims.lti-nrps.v2.membershipcontainer+json 67 | // TODO Needs to handle Link headers 68 | NRPSResponse response = client.getForObject(url, NRPSResponse.class); 69 | return response; 70 | } catch (JOSEException e) { 71 | throw new RuntimeException("Failed to sign JWT", e); 72 | } catch (UnsupportedEncodingException e) { 73 | // This should never happen 74 | throw new RuntimeException("Unable to find encoding.", e); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/OAuthAuthenticationFailureHandler.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.security.oauth2; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.security.core.AuthenticationException; 5 | import org.springframework.security.oauth2.core.OAuth2AuthenticationException; 6 | import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; 7 | 8 | import jakarta.servlet.ServletException; 9 | import jakarta.servlet.http.HttpServletRequest; 10 | import jakarta.servlet.http.HttpServletResponse; 11 | import java.io.IOException; 12 | 13 | public class OAuthAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { 14 | 15 | public void onAuthenticationFailure(HttpServletRequest request, 16 | HttpServletResponse response, AuthenticationException exception) 17 | throws IOException, ServletException { 18 | if (exception instanceof OAuth2AuthenticationException) { 19 | response.sendError(HttpStatus.UNAUTHORIZED.value(), ((OAuth2AuthenticationException)exception).getError().getErrorCode()+ " : "+ exception.getMessage()); 20 | } else { 21 | super.onAuthenticationFailure(request, response, exception); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/authentication/OidcAuthenticationToken.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.authentication; 2 | 3 | import org.springframework.security.core.GrantedAuthority; 4 | import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; 5 | import org.springframework.security.oauth2.core.user.OAuth2User; 6 | 7 | import java.util.Collection; 8 | 9 | /** 10 | * We are validating the state on the client (browser) so we need to be able to return the state/nonce back to the client 11 | * and so it needs to exist outside of just the authentication method. 12 | */ 13 | public class OidcAuthenticationToken extends OAuth2AuthenticationToken { 14 | private final String state; 15 | 16 | public OidcAuthenticationToken(OAuth2User principal, Collection authorities, String authorizedClientRegistrationId, String state) { 17 | super(principal, authorities, authorizedClientRegistrationId); 18 | this.state = state; 19 | } 20 | 21 | /** 22 | * @return returns the state that was passed in with the authentication. 23 | */ 24 | public String getState() { 25 | return state; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/authentication/OidcLaunchFlowAuthenticationProvider.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2002-2018 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.authentication; 17 | 18 | import org.springframework.security.authentication.AuthenticationProvider; 19 | import org.springframework.security.core.Authentication; 20 | import org.springframework.security.core.AuthenticationException; 21 | import org.springframework.security.core.GrantedAuthority; 22 | import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; 23 | import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; 24 | import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; 25 | import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; 26 | import org.springframework.security.oauth2.client.registration.ClientRegistration; 27 | import org.springframework.security.oauth2.core.OAuth2AuthenticationException; 28 | import org.springframework.security.oauth2.core.OAuth2Error; 29 | import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; 30 | import org.springframework.security.oauth2.core.oidc.OidcIdToken; 31 | import org.springframework.security.oauth2.core.oidc.OidcScopes; 32 | import org.springframework.security.oauth2.core.oidc.user.OidcUser; 33 | import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority; 34 | import org.springframework.security.oauth2.jose.jws.JwsAlgorithms; 35 | import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; 36 | import org.springframework.security.oauth2.jwt.Jwt; 37 | import org.springframework.security.oauth2.jwt.JwtDecoder; 38 | import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; 39 | import org.springframework.util.Assert; 40 | import org.springframework.util.StringUtils; 41 | import org.springframework.web.client.RestOperations; 42 | import uk.ac.ox.ctl.lti13.security.oauth2.core.endpoint.OIDCLaunchFlowResponse; 43 | import uk.ac.ox.ctl.lti13.security.oauth2.core.user.LtiOauth2User; 44 | 45 | import java.util.Collection; 46 | import java.util.HashSet; 47 | import java.util.Map; 48 | import java.util.Set; 49 | import java.util.concurrent.ConcurrentHashMap; 50 | 51 | /** 52 | * An implementation of an {@link AuthenticationProvider} 53 | * for the IMS SEC 1.0 OpenID Connect Launch Flow 54 | *

55 | * This {@link AuthenticationProvider} is responsible for authenticating 56 | * an ID Token in an OpenID Implicit flow. 57 | *

58 | * It will create a {@code Principal} in the form of an {@link OidcUser}. 59 | * The {@code OidcUser} is then associated to the {@link OAuth2LoginAuthenticationToken} 60 | * to complete the authentication. 61 | * 62 | * @author Joe Grandja 63 | * @since 5.0 64 | * @see OAuth2LoginAuthenticationToken 65 | * @see OAuth2AccessTokenResponseClient 66 | * @see OidcUserService 67 | * @see OidcUser 68 | * @see Section 3.2 Authentication using the Implicit Flow 69 | * @see 3.2.2.1. Authentication Request 70 | * @see 3.2.2.5. Successful Authentication Response 71 | */ 72 | public class OidcLaunchFlowAuthenticationProvider implements AuthenticationProvider { 73 | private static final String INVALID_STATE_PARAMETER_ERROR_CODE = "invalid_state_parameter"; 74 | private static final String MISSING_SIGNATURE_VERIFIER_ERROR_CODE = "missing_signature_verifier"; 75 | private final Map jwtDecoders = new ConcurrentHashMap<>(); 76 | private GrantedAuthoritiesMapper authoritiesMapper = (authorities -> authorities); 77 | private RestOperations restOperations; 78 | 79 | @Override 80 | public Authentication authenticate(Authentication authentication) throws AuthenticationException { 81 | OidcLaunchFlowToken authorizationCodeAuthentication = 82 | (OidcLaunchFlowToken) authentication; 83 | 84 | // Section 3.1.2.1 Authentication Request - https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest 85 | // scope 86 | // REQUIRED. OpenID Connect requests MUST contain the "openid" scope value. 87 | if (!authorizationCodeAuthentication.getAuthorizationExchange() 88 | .getAuthorizationRequest().getScopes().contains(OidcScopes.OPENID)) { 89 | // This is NOT an OpenID Connect Authentication Request so return null 90 | // and let OAuth2LoginAuthenticationProvider handle it instead 91 | return null; 92 | } 93 | 94 | OAuth2AuthorizationRequest authorizationRequest = authorizationCodeAuthentication 95 | .getAuthorizationExchange().getAuthorizationRequest(); 96 | OIDCLaunchFlowResponse authorizationResponse = authorizationCodeAuthentication 97 | .getAuthorizationExchange().getAuthorizationResponse(); 98 | 99 | if (authorizationResponse.statusError()) { 100 | throw new OAuth2AuthenticationException( 101 | authorizationResponse.getError(), authorizationResponse.getError().toString()); 102 | } 103 | 104 | if (!authorizationResponse.getState().equals(authorizationRequest.getState())) { 105 | OAuth2Error oauth2Error = new OAuth2Error(INVALID_STATE_PARAMETER_ERROR_CODE); 106 | throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); 107 | } 108 | 109 | ClientRegistration clientRegistration = authorizationCodeAuthentication.getClientRegistration(); 110 | 111 | OidcIdToken idToken = createOidcToken(clientRegistration, authorizationResponse.getIdToken()); 112 | 113 | // We don't have a userinfo endpoint so just construct our user from the claims in the ID Token 114 | Set authorities = new HashSet<>(); 115 | OidcUserAuthority authority = new OidcUserAuthority(idToken, null); 116 | authorities.add(authority); 117 | LtiOauth2User oidcUser = new LtiOauth2User(authorities, idToken); 118 | 119 | Collection mappedAuthorities = 120 | this.authoritiesMapper.mapAuthorities(oidcUser.getAuthorities()); 121 | 122 | OidcLaunchFlowToken authenticationResult = new OidcLaunchFlowToken( 123 | authorizationCodeAuthentication.getClientRegistration(), 124 | authorizationCodeAuthentication.getAuthorizationExchange(), 125 | oidcUser, 126 | mappedAuthorities); 127 | authenticationResult.setDetails(authorizationCodeAuthentication.getDetails()); 128 | 129 | return authenticationResult; 130 | } 131 | 132 | /** 133 | * Sets the {@link GrantedAuthoritiesMapper} used for mapping {@link OidcUser#getAuthorities()}} 134 | * to a new set of authorities which will be associated to the {@link OAuth2LoginAuthenticationToken}. 135 | * 136 | * @param authoritiesMapper the {@link GrantedAuthoritiesMapper} used for mapping the user's authorities 137 | */ 138 | public final void setAuthoritiesMapper(GrantedAuthoritiesMapper authoritiesMapper) { 139 | Assert.notNull(authoritiesMapper, "authoritiesMapper cannot be null"); 140 | this.authoritiesMapper = authoritiesMapper; 141 | } 142 | 143 | /** 144 | * Sets the {@link RestOperations} used to retrieve the JWKs URL. 145 | * 146 | * @param restOperations the {@link RestOperations} used to retrieve the JWKs URI. 147 | */ 148 | public final void setRestOperations(RestOperations restOperations) { 149 | this.restOperations = restOperations; 150 | } 151 | 152 | @Override 153 | public boolean supports(Class authentication) { 154 | return OidcLaunchFlowToken.class.isAssignableFrom(authentication); 155 | } 156 | 157 | private OidcIdToken createOidcToken(ClientRegistration clientRegistration, String idToken) { 158 | JwtDecoder jwtDecoder = getJwtDecoder(clientRegistration); 159 | Jwt jwt = jwtDecoder.decode(idToken); 160 | OidcIdToken oidcIdToken = new OidcIdToken(jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(), jwt.getClaims()); 161 | OidcTokenValidator.validateIdToken(oidcIdToken, clientRegistration); 162 | return oidcIdToken; 163 | } 164 | 165 | private JwtDecoder getJwtDecoder(ClientRegistration clientRegistration) { 166 | JwtDecoder jwtDecoder = this.jwtDecoders.get(clientRegistration.getRegistrationId()); 167 | if (jwtDecoder == null) { 168 | if (!StringUtils.hasText(clientRegistration.getProviderDetails().getJwkSetUri())) { 169 | OAuth2Error oauth2Error = new OAuth2Error( 170 | MISSING_SIGNATURE_VERIFIER_ERROR_CODE, 171 | "Failed to find a Signature Verifier for Client Registration: '" + 172 | clientRegistration.getRegistrationId() + "'. Check to ensure you have configured the JwkSet URI.", 173 | null 174 | ); 175 | throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); 176 | } 177 | // TODO This should look at the Cache-Control header so to expire old jwtDecoders. 178 | // Canvas looks to rotate it's keys monthly. 179 | String jwkSetUri = clientRegistration.getProviderDetails().getJwkSetUri(); 180 | NimbusJwtDecoder.JwkSetUriJwtDecoderBuilder decoderBuilder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).jwsAlgorithm(SignatureAlgorithm.from(JwsAlgorithms.RS256)); 181 | if (restOperations != null) { 182 | decoderBuilder.restOperations(restOperations); 183 | } 184 | jwtDecoder = decoderBuilder.build(); 185 | this.jwtDecoders.put(clientRegistration.getRegistrationId(), jwtDecoder); 186 | } 187 | return jwtDecoder; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/authentication/OidcLaunchFlowToken.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2002-2018 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.authentication; 17 | 18 | import org.springframework.security.authentication.AbstractAuthenticationToken; 19 | import org.springframework.security.core.GrantedAuthority; 20 | import org.springframework.security.core.SpringSecurityCoreVersion; 21 | import org.springframework.security.oauth2.client.registration.ClientRegistration; 22 | import org.springframework.security.oauth2.core.OAuth2AccessToken; 23 | import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; 24 | import org.springframework.security.oauth2.core.oidc.user.OidcUser; 25 | import org.springframework.security.oauth2.core.user.OAuth2User; 26 | import org.springframework.util.Assert; 27 | import uk.ac.ox.ctl.lti13.security.oauth2.core.endpoint.OIDCLaunchFlowExchange; 28 | 29 | import java.util.Collection; 30 | import java.util.Collections; 31 | 32 | /** 33 | * An {@link AbstractAuthenticationToken} for OAuth 2.0 Login, 34 | * which leverages the OAuth 2.0 Authorization Implicit Grant Flow. 35 | * 36 | * @author Matthew Buckett 37 | * @since 5.0 38 | * @see AbstractAuthenticationToken 39 | * @see OAuth2User 40 | * @see ClientRegistration 41 | * @see OAuth2AuthorizationExchange 42 | * @see OAuth2AccessToken 43 | * @see Section 4.1 Authorization Code Grant Flow 44 | */ 45 | public class OidcLaunchFlowToken extends AbstractAuthenticationToken { 46 | private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; 47 | private OidcUser principal; 48 | private ClientRegistration clientRegistration; 49 | private OIDCLaunchFlowExchange authorizationExchange; 50 | 51 | /** 52 | * This constructor should be used when the Authorization Request/Response is complete. 53 | * 54 | * @param clientRegistration the client registration 55 | * @param authorizationExchange the authorization exchange 56 | */ 57 | public OidcLaunchFlowToken(ClientRegistration clientRegistration, 58 | OIDCLaunchFlowExchange authorizationExchange) { 59 | 60 | super(Collections.emptyList()); 61 | Assert.notNull(clientRegistration, "clientRegistration cannot be null"); 62 | Assert.notNull(authorizationExchange, "authorizationExchange cannot be null"); 63 | this.clientRegistration = clientRegistration; 64 | this.authorizationExchange = authorizationExchange; 65 | this.setAuthenticated(false); 66 | } 67 | 68 | /** 69 | * This constructor should be used when the Access Token Request/Response is complete, 70 | * which indicates that the Authorization Code Grant flow has fully completed 71 | * and OAuth 2.0 Login has been achieved. 72 | * 73 | * @param clientRegistration the client registration 74 | * @param authorizationExchange the authorization exchange 75 | * @param principal the user {@code Principal} registered with the OAuth 2.0 Provider 76 | * @param authorities the authorities granted to the user 77 | */ 78 | public OidcLaunchFlowToken(ClientRegistration clientRegistration, 79 | OIDCLaunchFlowExchange authorizationExchange, 80 | OidcUser principal, 81 | Collection authorities) { 82 | super(authorities); 83 | Assert.notNull(clientRegistration, "clientRegistration cannot be null"); 84 | Assert.notNull(authorizationExchange, "authorizationExchange cannot be null"); 85 | Assert.notNull(principal, "principal cannot be null"); 86 | this.clientRegistration = clientRegistration; 87 | this.authorizationExchange = authorizationExchange; 88 | this.principal = principal; 89 | this.setAuthenticated(true); 90 | } 91 | 92 | @Override 93 | public OidcUser getPrincipal() { 94 | return this.principal; 95 | } 96 | 97 | @Override 98 | public Object getCredentials() { 99 | return ""; 100 | } 101 | 102 | /** 103 | * Returns the {@link ClientRegistration client registration}. 104 | * 105 | * @return the {@link ClientRegistration} 106 | */ 107 | public ClientRegistration getClientRegistration() { 108 | return this.clientRegistration; 109 | } 110 | 111 | /** 112 | * Returns the {@link OAuth2AuthorizationExchange authorization exchange}. 113 | * 114 | * @return the {@link OAuth2AuthorizationExchange} 115 | */ 116 | public OIDCLaunchFlowExchange getAuthorizationExchange() { 117 | return this.authorizationExchange; 118 | } 119 | 120 | } 121 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/authentication/OidcTokenValidator.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2002-2018 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.authentication; 18 | 19 | import org.springframework.security.oauth2.client.registration.ClientRegistration; 20 | import org.springframework.security.oauth2.core.OAuth2AuthenticationException; 21 | import org.springframework.security.oauth2.core.OAuth2Error; 22 | import org.springframework.security.oauth2.core.OAuth2TokenValidator; 23 | import org.springframework.security.oauth2.core.oidc.OidcIdToken; 24 | import org.springframework.util.CollectionUtils; 25 | 26 | import java.net.URL; 27 | import java.time.Instant; 28 | import java.util.List; 29 | 30 | /** 31 | * This needs to be update for the launch flow for IMS Sec 1.0 32 | * We should refactor this so that it implements org.springframework.security.oauth2.core.OAuth2TokenValidator 33 | * @author Rob Winch 34 | * @since 5.1 35 | * @see OAuth2TokenValidator 36 | */ 37 | final class OidcTokenValidator { 38 | private static final String INVALID_ID_TOKEN_ERROR_CODE = "invalid_id_token"; 39 | 40 | static void validateIdToken(OidcIdToken idToken, ClientRegistration clientRegistration) { 41 | // 3.1.3.7 ID Token Validation 42 | // https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation 43 | 44 | // Validate REQUIRED Claims 45 | URL issuer = idToken.getIssuer(); 46 | if (issuer == null) { 47 | throwInvalidIdTokenException("No issuer in token."); 48 | } 49 | // We don't validate that there's a subject claim as an anonymous launch doesn't include one. 50 | List audience = idToken.getAudience(); 51 | if (CollectionUtils.isEmpty(audience)) { 52 | throwInvalidIdTokenException("No audience in token."); 53 | } 54 | Instant expiresAt = idToken.getExpiresAt(); 55 | if (expiresAt == null) { 56 | throwInvalidIdTokenException("No expiry timestamp in token."); 57 | } 58 | Instant issuedAt = idToken.getIssuedAt(); 59 | if (issuedAt == null) { 60 | throwInvalidIdTokenException("No issue timestamp in token."); 61 | } 62 | 63 | // 2. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) 64 | // MUST exactly match the value of the iss (issuer) Claim. 65 | String requiredIssuer = clientRegistration.getProviderDetails().getIssuerUri(); 66 | if (requiredIssuer != null && !requiredIssuer.equals(issuer.toExternalForm())) { 67 | throwInvalidIdTokenException("Issuer doesn't match issuer in token."); 68 | } 69 | 70 | // 3. The Client MUST validate that the aud (audience) Claim contains its client_id value 71 | // registered at the Issuer identified by the iss (issuer) Claim as an audience. 72 | // The aud (audience) Claim MAY contain an array with more than one element. 73 | // The ID Token MUST be rejected if the ID Token does not list the Client as a valid audience, 74 | // or if it contains additional audiences not trusted by the Client. 75 | if (!audience.contains(clientRegistration.getClientId())) { 76 | throwInvalidIdTokenException("Client ID not found for audience in token."); 77 | } 78 | 79 | // 4. If the ID Token contains multiple audiences, 80 | // the Client SHOULD verify that an azp Claim is present. 81 | String authorizedParty = idToken.getAuthorizedParty(); 82 | if (audience.size() > 1 && authorizedParty == null) { 83 | throwInvalidIdTokenException("Multiple audiences and no authorized party in token."); 84 | } 85 | 86 | // 5. If an azp (authorized party) Claim is present, 87 | // the Client SHOULD verify that its client_id is the Claim Value. 88 | if (authorizedParty != null && !authorizedParty.equals(clientRegistration.getClientId())) { 89 | throwInvalidIdTokenException("Authorized party doesn't match client ID in token."); 90 | } 91 | 92 | // 7. The alg value SHOULD be the default of RS256 or the algorithm sent by the Client 93 | // in the id_token_signed_response_alg parameter during Registration. 94 | // TODO Depends on gh-4413 95 | 96 | // 9. The current time MUST be before the time represented by the exp Claim. 97 | Instant now = Instant.now(); 98 | if (!now.isBefore(expiresAt)) { 99 | throwInvalidIdTokenException("Token expiry timestamp is in the future."); 100 | } 101 | 102 | // 10. The iat Claim can be used to reject tokens that were issued too far away from the current time, 103 | // limiting the amount of time that nonces need to be stored to prevent attacks. 104 | // The acceptable range is Client specific. 105 | Instant maxIssuedAt = now.plusSeconds(30); 106 | if (issuedAt.isAfter(maxIssuedAt)) { 107 | throwInvalidIdTokenException(); 108 | } 109 | 110 | // 11. If a nonce value was sent in the Authentication Request, 111 | // a nonce Claim MUST be present and its value checked to verify 112 | // that it is the same value as the one that was sent in the Authentication Request. 113 | // The Client SHOULD check the nonce value for replay attacks. 114 | // The precise method for detecting replay attacks is Client specific. 115 | // TODO Depends on gh-4442 116 | // TODO Must validate this although the state is like a nonce. 117 | 118 | // These are the LTI Claims that we check https://www.imsglobal.org/spec/lti/v1p3/#required-message-claims 119 | 120 | String ltiVersion = idToken.getClaimAsString("https://purl.imsglobal.org/spec/lti/claim/version"); 121 | if (!"1.3.0".equals(ltiVersion)) { 122 | throwInvalidIdTokenException("Must be LTI 1.3.0 version claim in token."); 123 | } 124 | 125 | String messageType = idToken.getClaimAsString("https://purl.imsglobal.org/spec/lti/claim/message_type"); 126 | if (messageType == null || messageType.isEmpty()) { 127 | throwInvalidIdTokenException("Message type claim missing from token."); 128 | } 129 | 130 | List roles = idToken.getClaimAsStringList("https://purl.imsglobal.org/spec/lti/claim/roles"); 131 | if (roles == null) { 132 | throwInvalidIdTokenException("Roles claim missing from token."); 133 | } 134 | // TODO If there are roles should check one matches the known roles. 135 | 136 | String deploymentId = idToken.getClaimAsString("https://purl.imsglobal.org/spec/lti/claim/deployment_id"); 137 | if (deploymentId == null || deploymentId.isEmpty()) { 138 | throwInvalidIdTokenException("Deployment ID claim missing from token."); 139 | } 140 | 141 | } 142 | 143 | private static void throwInvalidIdTokenException() { 144 | throwInvalidIdTokenException(null); 145 | } 146 | 147 | private static void throwInvalidIdTokenException(String message) throws OAuth2AuthenticationException { 148 | OAuth2Error invalidIdTokenError = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE); 149 | throw new OAuth2AuthenticationException(invalidIdTokenError, (message != null)?message:invalidIdTokenError.toString()); 150 | } 151 | 152 | private OidcTokenValidator() {} 153 | } 154 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/authentication/TargetLinkUriAuthenticationSuccessHandler.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.authentication; 2 | 3 | import org.springframework.security.core.Authentication; 4 | import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; 5 | import uk.ac.ox.ctl.lti13.lti.Claims; 6 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web.OptimisticAuthorizationRequestRepository; 7 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web.StateCheckingAuthenticationSuccessHandler; 8 | 9 | import jakarta.servlet.http.HttpServletRequest; 10 | import jakarta.servlet.http.HttpServletResponse; 11 | 12 | /** 13 | * This looks for the target URI in the final request (as it's signed by the platform). 14 | */ 15 | public class TargetLinkUriAuthenticationSuccessHandler extends StateCheckingAuthenticationSuccessHandler { 16 | 17 | /** 18 | * @param authorizationRequestRepository The authentication repository. 19 | */ 20 | public TargetLinkUriAuthenticationSuccessHandler(OptimisticAuthorizationRequestRepository authorizationRequestRepository) { 21 | super(authorizationRequestRepository); 22 | } 23 | 24 | @Override 25 | protected String determineTargetUrl(HttpServletRequest request, 26 | HttpServletResponse response, Authentication authentication) { 27 | if (authentication instanceof OAuth2AuthenticationToken) { 28 | OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) authentication; 29 | // https://www.imsglobal.org/spec/lti/v1p3/#target-link-uri says we should only trust this and not 30 | // the parameter passed in on the initial login initiation request. 31 | String targetLink = token.getPrincipal().getAttribute(Claims.TARGET_LINK_URI); 32 | if (targetLink != null && !targetLink.isEmpty()) { 33 | return targetLink; 34 | } 35 | } 36 | return super.determineTargetUrl(request, response, authentication); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/AuthorizationRedirectHandler.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | import jakarta.servlet.http.HttpServletResponse; 5 | import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; 6 | 7 | import java.io.IOException; 8 | 9 | public interface AuthorizationRedirectHandler { 10 | 11 | /** 12 | * Send a redirect to the user to start authorization but make the authorization request available. 13 | * @param request The HttpServletRequest that came in. 14 | * @param response The HttpServletResponse that we are writing the output to. 15 | * @param authorizationRequest Details of the OAuth request. 16 | * @throws IOException If there's a IO problem sending the redirect. 17 | */ 18 | void sendRedirect(HttpServletRequest request, HttpServletResponse response, OAuth2AuthorizationRequest authorizationRequest) throws IOException; 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/HttpSessionOAuth2AuthorizationRequestRepository.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2002-2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web; 18 | 19 | import java.util.HashMap; 20 | import java.util.LinkedHashMap; 21 | import java.util.Map; 22 | 23 | import jakarta.servlet.http.HttpServletRequest; 24 | import jakarta.servlet.http.HttpServletResponse; 25 | import jakarta.servlet.http.HttpSession; 26 | 27 | import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; 28 | import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; 29 | import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; 30 | import org.springframework.util.Assert; 31 | 32 | /** 33 | * An implementation of an {@link AuthorizationRequestRepository} that stores 34 | * {@link OAuth2AuthorizationRequest} in the {@code HttpSession}. This was copied from Spring Security, but we added 35 | * support for limiting the number of concurrent sessions, this is so that when we have multiple LTI launches on the 36 | * same page they work (as the browser will kick them all off at the same time). 37 | * 38 | * @author Joe Grandja 39 | * @author Rob Winch 40 | * @author Craig Andrews 41 | * @author Matthew Buckett 42 | * @since 5.0 43 | * @see AuthorizationRequestRepository 44 | * @see OAuth2AuthorizationRequest 45 | */ 46 | public final class HttpSessionOAuth2AuthorizationRequestRepository 47 | implements AuthorizationRequestRepository { 48 | 49 | private static final String DEFAULT_AUTHORIZATION_REQUEST_ATTR_NAME = HttpSessionOAuth2AuthorizationRequestRepository.class 50 | .getName() + ".AUTHORIZATION_REQUEST"; 51 | 52 | private final String sessionAttributeName = DEFAULT_AUTHORIZATION_REQUEST_ATTR_NAME; 53 | 54 | private int maxConcurrentLogins = 1; 55 | 56 | @Override 57 | public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { 58 | Assert.notNull(request, "request cannot be null"); 59 | String stateParameter = this.getStateParameter(request); 60 | if (stateParameter == null) { 61 | return null; 62 | } 63 | Map authorizationRequests = this.getAuthorizationRequests(request); 64 | return authorizationRequests.get(stateParameter); 65 | } 66 | 67 | @Override 68 | public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, 69 | HttpServletResponse response) { 70 | Assert.notNull(request, "request cannot be null"); 71 | Assert.notNull(response, "response cannot be null"); 72 | if (authorizationRequest == null) { 73 | this.removeAuthorizationRequest(request, response); 74 | return; 75 | } 76 | String state = authorizationRequest.getState(); 77 | Assert.hasText(state, "authorizationRequest.state cannot be empty"); 78 | if (this.maxConcurrentLogins > 1) { 79 | Map authorizationRequests = this.getAuthorizationRequests(request); 80 | authorizationRequests.put(state, authorizationRequest); 81 | request.getSession().setAttribute(this.sessionAttributeName, authorizationRequests); 82 | } 83 | else { 84 | request.getSession().setAttribute(this.sessionAttributeName, authorizationRequest); 85 | } 86 | } 87 | 88 | @Override 89 | public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, 90 | HttpServletResponse response) { 91 | Assert.notNull(response, "response cannot be null"); 92 | Assert.notNull(request, "request cannot be null"); 93 | String stateParameter = this.getStateParameter(request); 94 | if (stateParameter == null) { 95 | return null; 96 | } 97 | Map authorizationRequests = this.getAuthorizationRequests(request); 98 | OAuth2AuthorizationRequest originalRequest = authorizationRequests.remove(stateParameter); 99 | if (authorizationRequests.isEmpty()) { 100 | request.getSession().removeAttribute(this.sessionAttributeName); 101 | } 102 | else if (authorizationRequests.size() == 1) { 103 | request.getSession().setAttribute(this.sessionAttributeName, 104 | authorizationRequests.values().iterator().next()); 105 | } 106 | else { 107 | request.getSession().setAttribute(this.sessionAttributeName, authorizationRequests); 108 | } 109 | return originalRequest; 110 | } 111 | 112 | /** 113 | * Gets the state parameter from the {@link HttpServletRequest} 114 | * @param request the request to use 115 | * @return the state parameter or null if not found 116 | */ 117 | private String getStateParameter(HttpServletRequest request) { 118 | return request.getParameter(OAuth2ParameterNames.STATE); 119 | } 120 | 121 | /** 122 | * Gets a non-null and mutable map of {@link OAuth2AuthorizationRequest#getState()} to 123 | * an {@link OAuth2AuthorizationRequest} 124 | * @param request 125 | * @return a non-null and mutable map of {@link OAuth2AuthorizationRequest#getState()} 126 | * to an {@link OAuth2AuthorizationRequest}. 127 | */ 128 | private Map getAuthorizationRequests(HttpServletRequest request) { 129 | HttpSession session = request.getSession(false); 130 | Object sessionAttributeValue = (session != null) ? session.getAttribute(this.sessionAttributeName) : null; 131 | if (sessionAttributeValue == null) { 132 | return new HashMap<>(); 133 | } 134 | else if (sessionAttributeValue instanceof OAuth2AuthorizationRequest auth2AuthorizationRequest) { 135 | Map authorizationRequests = createLRUMap(maxConcurrentLogins); 136 | authorizationRequests.put(auth2AuthorizationRequest.getState(), auth2AuthorizationRequest); 137 | return authorizationRequests; 138 | } 139 | else if (sessionAttributeValue instanceof Map) { 140 | @SuppressWarnings("unchecked") 141 | Map authorizationRequests = (Map) sessionAttributeValue; 142 | return authorizationRequests; 143 | } 144 | else { 145 | throw new IllegalStateException( 146 | "authorizationRequests is supposed to be a Map or OAuth2AuthorizationRequest but actually is a " 147 | + sessionAttributeValue.getClass()); 148 | } 149 | } 150 | 151 | /** 152 | * Configure number of multiple {@link OAuth2AuthorizationRequest}s should be stored per 153 | * session. Default is 1 (not allow multiple {@link OAuth2AuthorizationRequest} 154 | * per session). 155 | * @param maxConcurrentLogins true allows more than one 156 | * {@link OAuth2AuthorizationRequest} to be stored per session. 157 | * 158 | */ 159 | public void setMaxConcurrentLogins(int maxConcurrentLogins) { 160 | this.maxConcurrentLogins = maxConcurrentLogins; 161 | } 162 | 163 | /** 164 | * Creates least recently used hashmap. 165 | * @see Stackoverflow 166 | * @param maxEntries Maximum number of entries in the map 167 | * @param Key type. 168 | * @param Value type. 169 | * @return a LinkedHashMap that limits its size. 170 | */ 171 | public static Map createLRUMap(final int maxEntries) { 172 | return new LinkedHashMap(maxEntries*10/7, 0.7f, true) { 173 | @Override 174 | protected boolean removeEldestEntry(Map.Entry eldest) { 175 | return size() > maxEntries; 176 | } 177 | }; 178 | } 179 | 180 | } 181 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/InvalidClientRegistrationIdException.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web; 2 | 3 | /** 4 | * This is for when we fail to find a client registration. 5 | */ 6 | class InvalidClientRegistrationIdException extends IllegalArgumentException { 7 | 8 | /** 9 | * @param message the exception message 10 | */ 11 | InvalidClientRegistrationIdException(String message) { 12 | super(message); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/InvalidInitiationRequestException.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web; 2 | 3 | /** 4 | * This is for use when the client sends a request that's missing required parameters. 5 | */ 6 | public class InvalidInitiationRequestException extends RuntimeException { 7 | 8 | /** 9 | * @param message the exception message 10 | */ 11 | InvalidInitiationRequestException(String message) { 12 | super(message); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/LTIAuthorizationGrantType.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web; 2 | 3 | import org.springframework.security.oauth2.core.AuthorizationGrantType; 4 | 5 | public interface LTIAuthorizationGrantType { 6 | 7 | /** 8 | * The LTI grant type when launching a tool.This is needed because Spring Security itself 9 | * doesn't support the implicit grant type anymore. 10 | */ 11 | AuthorizationGrantType IMPLICIT = new AuthorizationGrantType("implicit"); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/OAuth2AuthorizationRequestRedirectFilter.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2002-2018 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web; 17 | 18 | import org.springframework.http.HttpStatus; 19 | import org.springframework.security.oauth2.client.ClientAuthorizationRequiredException; 20 | import org.springframework.security.oauth2.client.registration.ClientRegistration; 21 | import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; 22 | import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; 23 | import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; 24 | import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; 25 | import org.springframework.security.oauth2.core.AuthorizationGrantType; 26 | import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; 27 | import org.springframework.security.web.DefaultRedirectStrategy; 28 | import org.springframework.security.web.RedirectStrategy; 29 | import org.springframework.security.web.util.ThrowableAnalyzer; 30 | import org.springframework.util.Assert; 31 | import org.springframework.web.filter.OncePerRequestFilter; 32 | 33 | import jakarta.servlet.FilterChain; 34 | import jakarta.servlet.ServletException; 35 | import jakarta.servlet.http.HttpServletRequest; 36 | import jakarta.servlet.http.HttpServletResponse; 37 | import java.io.IOException; 38 | import java.time.Duration; 39 | 40 | /** 41 | * This {@code Filter} initiates the authorization code grant or implicit grant flow 42 | * by redirecting the End-User's user-agent to the Authorization Server's Authorization Endpoint. 43 | * 44 | *

45 | * It builds the OAuth 2.0 Authorization Request, 46 | * which is used as the redirect {@code URI} to the Authorization Endpoint. 47 | * The redirect {@code URI} will include the client identifier, requested scope(s), state, 48 | * response type, and a redirection URI which the authorization server will send the user-agent back to 49 | * once access is granted (or denied) by the End-User (Resource Owner). 50 | * 51 | *

52 | * By default, this {@code Filter} responds to authorization requests 53 | * at the {@code URI} {@code /oauth2/authorization/{registrationId}} 54 | * using the default {@link OAuth2AuthorizationRequestResolver}. 55 | * The {@code URI} template variable {@code {registrationId}} represents the 56 | * {@link ClientRegistration#getRegistrationId() registration identifier} of the client 57 | * that is used for initiating the OAuth 2.0 Authorization Request. 58 | * 59 | *

60 | * The default base {@code URI} {@code /oauth2/authorization} may be overridden 61 | * via the constructor {@link #OAuth2AuthorizationRequestRedirectFilter(ClientRegistrationRepository, String)}, 62 | * or alternatively, an {@code OAuth2AuthorizationRequestResolver} may be provided to the constructor 63 | * {@link #OAuth2AuthorizationRequestRedirectFilter(OAuth2AuthorizationRequestResolver)} 64 | * to override the resolving of authorization requests. 65 | 66 | * @author Joe Grandja 67 | * @author Rob Winch 68 | * @since 5.0 69 | * @see OAuth2AuthorizationRequest 70 | * @see OAuth2AuthorizationRequestResolver 71 | * @see AuthorizationRequestRepository 72 | * @see ClientRegistration 73 | * @see ClientRegistrationRepository 74 | * @see Section 4.1 Authorization Code Grant 75 | * @see Section 4.1.1 Authorization Request (Authorization Code) 76 | * @see Section 4.2 Implicit Grant 77 | * @see Section 4.2.1 Authorization Request (Implicit) 78 | */ 79 | public class OAuth2AuthorizationRequestRedirectFilter extends OncePerRequestFilter { 80 | 81 | private final ThrowableAnalyzer throwableAnalyzer = new DefaultThrowableAnalyzer(); 82 | private final RedirectStrategy authorizationRedirectStrategy = new DefaultRedirectStrategy(); 83 | private OAuth2AuthorizationRequestResolver authorizationRequestResolver; 84 | private OptimisticAuthorizationRequestRepository authorizationRequestRepository = 85 | new OptimisticAuthorizationRequestRepository( 86 | new HttpSessionOAuth2AuthorizationRequestRepository(), 87 | new StateAuthorizationRequestRepository(Duration.ofMinutes(1)) 88 | ); 89 | private AuthorizationRedirectHandler stateAuthorizationRedirectHandler = new StateAuthorizationRedirectHandler(); 90 | 91 | /** 92 | * Constructs an {@code OAuth2AuthorizationRequestRedirectFilter} using the provided parameters. 93 | * 94 | * @param clientRegistrationRepository the repository of client registrations 95 | * @param authorizationRequestBaseUri the base {@code URI} used for authorization requests 96 | */ 97 | public OAuth2AuthorizationRequestRedirectFilter(ClientRegistrationRepository clientRegistrationRepository, 98 | String authorizationRequestBaseUri) { 99 | Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null"); 100 | Assert.hasText(authorizationRequestBaseUri, "authorizationRequestBaseUri cannot be empty"); 101 | this.authorizationRequestResolver = new DefaultOAuth2AuthorizationRequestResolver( 102 | clientRegistrationRepository, authorizationRequestBaseUri); 103 | } 104 | 105 | /** 106 | * Constructs an {@code OAuth2AuthorizationRequestRedirectFilter} using the provided parameters. 107 | * 108 | * @since 5.1 109 | * @param authorizationRequestResolver the resolver used for resolving authorization requests 110 | */ 111 | public OAuth2AuthorizationRequestRedirectFilter(OAuth2AuthorizationRequestResolver authorizationRequestResolver) { 112 | Assert.notNull(authorizationRequestResolver, "authorizationRequestResolver cannot be null"); 113 | this.authorizationRequestResolver = authorizationRequestResolver; 114 | } 115 | 116 | /** 117 | * Sets the repository used for storing {@link OAuth2AuthorizationRequest}'s. 118 | * 119 | * @param authorizationRequestRepository the repository used for storing {@link OAuth2AuthorizationRequest}'s 120 | */ 121 | public final void setAuthorizationRequestRepository(OptimisticAuthorizationRequestRepository authorizationRequestRepository) { 122 | Assert.notNull(authorizationRequestRepository, "authorizationRequestRepository cannot be null"); 123 | this.authorizationRequestRepository = authorizationRequestRepository; 124 | } 125 | 126 | @Override 127 | protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) 128 | throws ServletException, IOException { 129 | 130 | try { 131 | OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestResolver.resolve(request); 132 | if (authorizationRequest != null) { 133 | this.sendRedirectForAuthorization(request, response, authorizationRequest); 134 | return; 135 | } 136 | } catch (Exception failed) { 137 | this.unsuccessfulRedirectForAuthorization(request, response, failed); 138 | return; 139 | } 140 | 141 | try { 142 | filterChain.doFilter(request, response); 143 | } catch (IOException ex) { 144 | throw ex; 145 | } catch (Exception ex) { 146 | // Check to see if we need to handle ClientAuthorizationRequiredException 147 | Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex); 148 | ClientAuthorizationRequiredException authzEx = (ClientAuthorizationRequiredException) this.throwableAnalyzer 149 | .getFirstThrowableOfType(ClientAuthorizationRequiredException.class, causeChain); 150 | if (authzEx != null) { 151 | try { 152 | OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestResolver.resolve(request, authzEx.getClientRegistrationId()); 153 | if (authorizationRequest == null) { 154 | throw authzEx; 155 | } 156 | this.sendRedirectForAuthorization(request, response, authorizationRequest); 157 | } catch (Exception failed) { 158 | this.unsuccessfulRedirectForAuthorization(request, response, failed); 159 | } 160 | return; 161 | } 162 | 163 | if (ex instanceof ServletException) { 164 | throw (ServletException) ex; 165 | } else if (ex instanceof RuntimeException) { 166 | throw (RuntimeException) ex; 167 | } else { 168 | throw new RuntimeException(ex); 169 | } 170 | } 171 | } 172 | 173 | private void sendRedirectForAuthorization(HttpServletRequest request, HttpServletResponse response, 174 | OAuth2AuthorizationRequest authorizationRequest) throws IOException { 175 | 176 | // LTI 1.3 is an implicit grant, but the Spring Security codebase doesn't support this anymore. 177 | // So we pretend that we are doing an auth code grant. 178 | if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(authorizationRequest.getGrantType())) { 179 | this.authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response); 180 | } 181 | 182 | // Currently in Canvas it always says we support the storage platform, but the mobile apps 183 | // currently don't and so shouldn't send through this parameter. 184 | if (authorizationRequestRepository.hasWorkingSession(request) || hasNoPlatform(request)) { 185 | // Standard session based usage so we just do a normal browser redirect. 186 | this.authorizationRedirectStrategy.sendRedirect(request, response, authorizationRequest.getAuthorizationRequestUri()); 187 | } else { 188 | // Want to pass in the authorizationRequest so we can pass the state to the browser. 189 | this.stateAuthorizationRedirectHandler.sendRedirect(request, response, authorizationRequest); 190 | } 191 | // We don't need to save the request as the final URL to redirect to is in the claims normally we would save 192 | // the current request here. 193 | } 194 | 195 | /** 196 | * Check that the LTI Storage Platform is unavailable. 197 | * @param request The HttpServletRequet to look into. 198 | * @return true if the service launching the LTI tool doesn't support the LTI Storage Platform. 199 | */ 200 | private boolean hasNoPlatform(HttpServletRequest request) { 201 | return request.getParameter("lti_storage_target") == null; 202 | } 203 | 204 | private void unsuccessfulRedirectForAuthorization(HttpServletRequest request, HttpServletResponse response, 205 | Exception failed) throws IOException, ServletException { 206 | 207 | if (failed instanceof InvalidInitiationRequestException) { 208 | logger.info("Invalid initiation request: "+ failed); 209 | response.sendError(HttpStatus.BAD_REQUEST.value(), failed.getMessage()); 210 | } else if (failed instanceof InvalidClientRegistrationIdException) { 211 | logger.info("Invalid registration ID: "+ failed); 212 | response.sendError(HttpStatus.NOT_FOUND.value(), failed.getMessage()); 213 | } else { 214 | logger.error("Authorization Request failed: " + failed.toString(), failed); 215 | response.sendError(HttpStatus.INTERNAL_SERVER_ERROR.value(), HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase()); 216 | } 217 | } 218 | 219 | private static final class DefaultThrowableAnalyzer extends ThrowableAnalyzer { 220 | protected void initExtractorMap() { 221 | super.initExtractorMap(); 222 | registerExtractor(ServletException.class, throwable -> { 223 | ThrowableAnalyzer.verifyThrowableHierarchy(throwable, ServletException.class); 224 | return ((ServletException) throwable).getRootCause(); 225 | }); 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/OAuth2LoginAuthenticationFilter.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2002-2018 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web; 17 | 18 | import org.springframework.security.authentication.AuthenticationManager; 19 | import org.springframework.security.core.Authentication; 20 | import org.springframework.security.core.AuthenticationException; 21 | import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; 22 | import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; 23 | import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationProvider; 24 | import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; 25 | import org.springframework.security.oauth2.client.registration.ClientRegistration; 26 | import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; 27 | import org.springframework.security.oauth2.client.web.*; 28 | import org.springframework.security.oauth2.core.OAuth2AuthenticationException; 29 | import org.springframework.security.oauth2.core.OAuth2Error; 30 | import org.springframework.security.oauth2.core.OAuth2ErrorCodes; 31 | import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; 32 | import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; 33 | import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; 34 | import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; 35 | import org.springframework.security.web.context.SecurityContextRepository; 36 | import org.springframework.util.Assert; 37 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.authentication.OidcLaunchFlowToken; 38 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.authentication.OidcAuthenticationToken; 39 | import uk.ac.ox.ctl.lti13.security.oauth2.core.endpoint.OIDCLaunchFlowExchange; 40 | import uk.ac.ox.ctl.lti13.security.oauth2.core.endpoint.OIDCLaunchFlowResponse; 41 | 42 | import jakarta.servlet.ServletException; 43 | import jakarta.servlet.http.HttpServletRequest; 44 | import jakarta.servlet.http.HttpServletResponse; 45 | import java.io.IOException; 46 | 47 | /** 48 | * An implementation of an {@link AbstractAuthenticationProcessingFilter} for OAuth 2.0 Login. 49 | * 50 | *

51 | * This authentication {@code Filter} handles the processing of an OAuth 2.0 Authorization Response 52 | * for the authorization code grant flow and delegates an {@link OAuth2LoginAuthenticationToken} 53 | * to the {@link AuthenticationManager} to log in the End-User. 54 | * 55 | *

56 | * The OAuth 2.0 Authorization Response is processed as follows: 57 | * 58 | *

    59 | *
  • 60 | * Assuming the End-User (Resource Owner) has granted access to the Client, the Authorization Server will append the 61 | * {@link OAuth2ParameterNames#CODE code} and {@link OAuth2ParameterNames#STATE state} parameters 62 | * to the {@link OAuth2ParameterNames#REDIRECT_URI redirect_uri} (provided in the Authorization Request) 63 | * and redirect the End-User's user-agent back to this {@code Filter} (the Client). 64 | *
  • 65 | *
  • 66 | * This {@code Filter} will then create an {@link OAuth2LoginAuthenticationToken} with 67 | * the {@link OAuth2ParameterNames#CODE code} received and 68 | * delegate it to the {@link AuthenticationManager} to authenticate. 69 | *
  • 70 | *
  • 71 | * Upon a successful authentication, an {@link OAuth2AuthenticationToken} is created (representing the End-User {@code Principal}) 72 | * and associated to the {@link OAuth2AuthorizedClient Authorized Client} using the {@link OAuth2AuthorizedClientRepository}. 73 | *
  • 74 | *
  • 75 | * Finally, the {@link OAuth2AuthenticationToken} is returned and ultimately stored 76 | * in the {@link SecurityContextRepository} to complete the authentication processing. 77 | *
  • 78 | *
79 | * 80 | * @author Joe Grandja 81 | * @see AbstractAuthenticationProcessingFilter 82 | * @see OAuth2LoginAuthenticationToken 83 | * @see OAuth2AuthenticationToken 84 | * @see OAuth2LoginAuthenticationProvider 85 | * @see OAuth2AuthorizationRequest 86 | * @see OAuth2AuthorizationResponse 87 | * @see AuthorizationRequestRepository 88 | * @see OAuth2AuthorizationRequestRedirectFilter 89 | * @see ClientRegistrationRepository 90 | * @see OAuth2AuthorizedClient 91 | * @see OAuth2AuthorizedClientRepository 92 | * @see Section 4.1 Authorization Code Grant 93 | * @see Section 4.1.2 Authorization Response 94 | * @since 5.0 95 | */ 96 | public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter { 97 | /** 98 | * The default {@code URI} where this {@code Filter} processes authentication requests. 99 | */ 100 | private static final String AUTHORIZATION_REQUEST_NOT_FOUND_ERROR_CODE = "authorization_request_not_found"; 101 | private static final String CLIENT_REGISTRATION_NOT_FOUND_ERROR_CODE = "client_registration_not_found"; 102 | private ClientRegistrationRepository clientRegistrationRepository; 103 | private AuthorizationRequestRepository authorizationRequestRepository = 104 | new HttpSessionOAuth2AuthorizationRequestRepository(); 105 | 106 | /** 107 | * Constructs an {@code OAuth2LoginAuthenticationFilter} using the provided parameters. 108 | * 109 | * @param clientRegistrationRepository the repository of client registrations 110 | * @param filterProcessesUrl the {@code URI} where this {@code Filter} will process the authentication requests 111 | * @since 5.1 112 | */ 113 | public OAuth2LoginAuthenticationFilter(ClientRegistrationRepository clientRegistrationRepository, 114 | String filterProcessesUrl) { 115 | super(filterProcessesUrl); 116 | Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null"); 117 | this.clientRegistrationRepository = clientRegistrationRepository; 118 | } 119 | 120 | @Override 121 | public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) 122 | throws AuthenticationException, IOException, ServletException { 123 | 124 | if (!isAuthorizationResponse(request)) { 125 | OAuth2Error oauth2Error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST); 126 | throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); 127 | } 128 | 129 | OAuth2AuthorizationRequest authorizationRequest = 130 | this.authorizationRequestRepository.removeAuthorizationRequest(request, response); 131 | if (authorizationRequest == null) { 132 | OAuth2Error oauth2Error = new OAuth2Error(AUTHORIZATION_REQUEST_NOT_FOUND_ERROR_CODE); 133 | throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); 134 | } 135 | 136 | String registrationId = (String) authorizationRequest.getAdditionalParameters().get(OAuth2ParameterNames.REGISTRATION_ID); 137 | ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId); 138 | if (clientRegistration == null) { 139 | OAuth2Error oauth2Error = new OAuth2Error(CLIENT_REGISTRATION_NOT_FOUND_ERROR_CODE, 140 | "Client Registration not found with Id: " + registrationId, null); 141 | throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); 142 | } 143 | 144 | String error = request.getParameter("error"); 145 | if (error != null) { 146 | String description = request.getParameter("error_description"); 147 | String uri = request.getParameter("error_uri"); 148 | OAuth2Error oAuth2Error = new OAuth2Error(error, description, uri); 149 | throw new OAuth2AuthenticationException(oAuth2Error, oAuth2Error.toString()); 150 | } 151 | 152 | String idToken = request.getParameter("id_token"); 153 | OIDCLaunchFlowResponse authorizationResponse = OIDCLaunchFlowResponse.success(idToken) 154 | .state(request.getParameter("state")) 155 | .build(); 156 | 157 | OidcLaunchFlowToken authenticationRequest = new OidcLaunchFlowToken( 158 | clientRegistration, new OIDCLaunchFlowExchange(authorizationRequest, authorizationResponse)); 159 | authenticationRequest.setDetails(this.authenticationDetailsSource.buildDetails(request)); 160 | 161 | OidcLaunchFlowToken authenticationResult = 162 | (OidcLaunchFlowToken) this.getAuthenticationManager().authenticate(authenticationRequest); 163 | 164 | // This is so that we can return the state to the client. 165 | OidcAuthenticationToken oidcAuthenticationToken = new OidcAuthenticationToken( 166 | authenticationResult.getPrincipal(), 167 | authenticationResult.getAuthorities(), 168 | authenticationResult.getClientRegistration().getRegistrationId(), 169 | authorizationResponse.getState() 170 | ); 171 | 172 | return oidcAuthenticationToken; 173 | } 174 | 175 | static boolean isAuthorizationResponse(HttpServletRequest request) { 176 | return isAuthorizationResponseSuccess(request) || isAuthorizationResponseError(request); 177 | } 178 | 179 | static boolean isAuthorizationResponseSuccess(HttpServletRequest request) { 180 | return request.getParameter("id_token") != null && request.getParameter("state") != null; 181 | } 182 | 183 | static boolean isAuthorizationResponseError(HttpServletRequest request) { 184 | return request.getParameter("error") != null && request.getParameter("state") != null; 185 | } 186 | 187 | /** 188 | * Sets the repository for stored {@link OAuth2AuthorizationRequest}'s. 189 | * 190 | * @param authorizationRequestRepository the repository for stored {@link OAuth2AuthorizationRequest}'s 191 | */ 192 | public final void setAuthorizationRequestRepository(AuthorizationRequestRepository authorizationRequestRepository) { 193 | Assert.notNull(authorizationRequestRepository, "authorizationRequestRepository cannot be null"); 194 | this.authorizationRequestRepository = authorizationRequestRepository; 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/OIDCInitiatingLoginRequestResolver.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | import org.springframework.security.crypto.keygen.KeyGenerators; 5 | import org.springframework.security.crypto.keygen.StringKeyGenerator; 6 | import org.springframework.security.oauth2.client.registration.ClientRegistration; 7 | import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; 8 | import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; 9 | import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; 10 | import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; 11 | import org.springframework.security.web.util.UrlUtils; 12 | import org.springframework.util.Assert; 13 | import org.springframework.web.util.UriComponentsBuilder; 14 | 15 | import java.util.HashMap; 16 | import java.util.Map; 17 | import java.util.UUID; 18 | 19 | /** 20 | * This handles the initial part of the LTI 1.3 Resource Link Request and sends the browser back to the platform to 21 | * start the OAuth2 Implicit Flow. 22 | * 23 | * We will mount a copy of the Filter at /lti/login_initiation 24 | * 25 | * As this is currently targeting LTI 1.3 it will come back as a login to the OAuth2 filter. 26 | * 27 | * @see OpenID ThirdPartyInitiatedLogin 28 | * @see IMS ThirdPartyInitiatedLogin 29 | */ 30 | public class OIDCInitiatingLoginRequestResolver implements OAuth2AuthorizationRequestResolver { 31 | 32 | private final ClientRegistrationRepository clientRegistrationRepository; 33 | private final OIDCInitiationRegistrationResolver registrationResolver; 34 | // The IMS LTI 1.3 Validator doesn't include = (%3D URL encoded) in state tokens. 35 | // private final StringKeyGenerator stateGenerator = new Base64StringKeyGenerator(Base64.getUrlEncoder()); 36 | private final StringKeyGenerator stateGenerator = KeyGenerators.string(); 37 | 38 | 39 | 40 | /** 41 | * Constructs a {@code DefaultOAuth2AuthorizationRequestResolver} using the provided parameters. 42 | * 43 | * @param clientRegistrationRepository the repository of client registrations 44 | * @param authorizationRequestBaseUri the base {@code URI} used for resolving authorization requests 45 | */ 46 | public OIDCInitiatingLoginRequestResolver(ClientRegistrationRepository clientRegistrationRepository, 47 | String authorizationRequestBaseUri) { 48 | Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null"); 49 | Assert.hasText(authorizationRequestBaseUri, "authorizationRequestBaseUri cannot be empty"); 50 | this.clientRegistrationRepository = clientRegistrationRepository; 51 | this.registrationResolver = new PathOIDCInitiationRegistrationResolver(authorizationRequestBaseUri); 52 | } 53 | 54 | /** 55 | * Constructs a {@code DefaultOAuth2AuthorizationRequestResolver} using the provided parameters. 56 | * 57 | * @param clientRegistrationRepository the repository of client registrations 58 | * @param registrationResolver a resolver that looks up the registration ID from the request 59 | */ 60 | public OIDCInitiatingLoginRequestResolver(ClientRegistrationRepository clientRegistrationRepository, 61 | OIDCInitiationRegistrationResolver registrationResolver) { 62 | Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null"); 63 | Assert.notNull(registrationResolver, "registrationResolver cannot be null"); 64 | this.clientRegistrationRepository = clientRegistrationRepository; 65 | this.registrationResolver = registrationResolver; 66 | } 67 | 68 | @Override 69 | public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { 70 | String registrationId = this.registrationResolver.resolve(request); 71 | return resolve(request, registrationId, "login"); 72 | } 73 | 74 | @Override 75 | public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String registrationId) { 76 | if (registrationId == null) { 77 | return null; 78 | } 79 | return resolve(request, registrationId, "login"); 80 | } 81 | 82 | private OAuth2AuthorizationRequest resolve(HttpServletRequest request, String registrationId, String redirectUriAction) { 83 | if (registrationId == null) { 84 | return null; 85 | } 86 | 87 | ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId); 88 | if (clientRegistration == null) { 89 | // We use a custom exception here so callers can specifically handle this case. 90 | throw new InvalidClientRegistrationIdException("No Client Registration found with ID: " + registrationId); 91 | } 92 | 93 | OAuth2AuthorizationRequest.Builder builder; 94 | if (LTIAuthorizationGrantType.IMPLICIT.equals(clientRegistration.getAuthorizationGrantType())) { 95 | // We are performing an implicit grand but this isn't supported by Spring Security any more 96 | // so we pretend it's actually auth code. 97 | builder = OAuth2AuthorizationRequest.authorizationCode(); 98 | } else { 99 | // This is a configuration problem. 100 | throw new IllegalArgumentException("Invalid Authorization Grant Type (" + 101 | clientRegistration.getAuthorizationGrantType().getValue() + 102 | ") for Client Registration with Id: " + clientRegistration.getRegistrationId()); 103 | } 104 | 105 | String iss = request.getParameter("iss"); 106 | if (iss == null) { 107 | throw new InvalidInitiationRequestException("Required parameter iss was not supplied."); 108 | } 109 | 110 | String loginHint = request.getParameter("login_hint"); 111 | if (loginHint == null) { 112 | throw new InvalidInitiationRequestException("Required parameter login_hint was not supplied."); 113 | } 114 | 115 | String targetLinkUri = request.getParameter("target_link_uri"); 116 | if (targetLinkUri == null) { 117 | throw new InvalidInitiationRequestException("Required parameter target_link_uri was not supplied"); 118 | } 119 | 120 | // The client_id parameter is optional, but if it's supplied check it matches. 121 | String clientId = request.getParameter("client_id"); 122 | if (clientId != null) { 123 | if (!clientId.equals(clientRegistration.getClientId())) { 124 | throw new IllegalArgumentException("Parameter client_id ("+clientId+") doesn't match the configured registration ("+ clientRegistration.getClientId()+")."); 125 | } 126 | } 127 | 128 | String redirectUriStr = this.expandRedirectUri(request, clientRegistration, redirectUriAction); 129 | 130 | 131 | Map additionalParameters = new HashMap<>(); 132 | additionalParameters.put(OAuth2ParameterNames.REGISTRATION_ID, clientRegistration.getRegistrationId()); 133 | // IMS SEC 1.0 134 | // OIDC allows for "id_token token" or "id_token". In LTI the id_token is also the access token. 135 | // This overrides the initial value set for this. 136 | additionalParameters.put(OAuth2ParameterNames.RESPONSE_TYPE, "id_token"); 137 | additionalParameters.put("login_hint", loginHint); 138 | additionalParameters.put("response_mode", "form_post"); 139 | // TODO We should really have a custom object for LTI launches 140 | additionalParameters.put("nonce", UUID.randomUUID().toString()); 141 | additionalParameters.put("prompt", "none"); 142 | 143 | // IMS LTI 1.3 144 | String ltiMessageHint = request.getParameter("lti_message_hint"); 145 | if (ltiMessageHint != null) { 146 | additionalParameters.put("lti_message_hint", ltiMessageHint); 147 | } 148 | 149 | // These are additional parameters that we want to keep but they aren't part of the message 150 | Map attributes = new HashMap<>(); 151 | // This is so that we can check the first and last requests of the login are from 152 | // the same IP address. 153 | attributes.put(StateAuthorizationRequestRepository.REMOTE_IP, request.getRemoteAddr()); 154 | 155 | OAuth2AuthorizationRequest authorizationRequest = builder 156 | .clientId(clientRegistration.getClientId()) 157 | .authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri()) 158 | .redirectUri(redirectUriStr) 159 | .scopes(clientRegistration.getScopes()) 160 | .state(this.stateGenerator.generateKey()) 161 | .additionalParameters(additionalParameters) 162 | .attributes(attributes) 163 | .build(); 164 | 165 | return authorizationRequest; 166 | } 167 | 168 | private String expandRedirectUri(HttpServletRequest request, ClientRegistration clientRegistration, String action) { 169 | // Supported URI variables -> baseUrl, action, registrationId 170 | // Used in -> CommonOAuth2Provider.DEFAULT_REDIRECT_URL = "{baseUrl}/{action}/oauth2/code/{registrationId}" 171 | Map uriVariables = new HashMap<>(); 172 | uriVariables.put("registrationId", clientRegistration.getRegistrationId()); 173 | String baseUrl = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request)) 174 | .replaceQuery(null) 175 | .replacePath(request.getContextPath()) 176 | .build() 177 | .toUriString(); 178 | uriVariables.put("baseUrl", baseUrl); 179 | if (action != null) { 180 | uriVariables.put("action", action); 181 | } 182 | return UriComponentsBuilder.fromUriString(clientRegistration.getRedirectUri()) 183 | .buildAndExpand(uriVariables) 184 | .toUriString(); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/OIDCInitiationRegistrationResolver.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web; 2 | 3 | 4 | import jakarta.servlet.http.HttpServletRequest; 5 | 6 | /** 7 | * Interface to allow custom ways of resolving the registration ID. Eg by client ID, or deployment ID. 8 | */ 9 | public interface OIDCInitiationRegistrationResolver { 10 | 11 | /* 12 | ** Finds a registration out of a request during a login initiation. 13 | */ 14 | String resolve(HttpServletRequest request); 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/OptimisticAuthorizationRequestRepository.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web; 2 | 3 | import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; 4 | import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; 5 | 6 | import jakarta.servlet.http.Cookie; 7 | import jakarta.servlet.http.HttpServletRequest; 8 | import jakarta.servlet.http.HttpServletResponse; 9 | 10 | /** 11 | * Checks to see if we already have a valid HTTP Session containing a token, if so we store details of the login 12 | * in the HTTP Session, otherwise we use store based on the state. 13 | */ 14 | public class OptimisticAuthorizationRequestRepository implements AuthorizationRequestRepository { 15 | 16 | public static final String ATTRIBUTE_NAME = OptimisticAuthorizationRequestRepository.class.getName() + "#WORKING_SESSION"; 17 | private final String COOKIE_NAME = "WORKING_COOKIES"; 18 | 19 | private final AuthorizationRequestRepository sessionBased; 20 | private final AuthorizationRequestRepository stateBased; 21 | 22 | public OptimisticAuthorizationRequestRepository(AuthorizationRequestRepository sessionBased, AuthorizationRequestRepository stateBased) { 23 | this.sessionBased = sessionBased; 24 | this.stateBased = stateBased; 25 | } 26 | 27 | public boolean hasWorkingSession(HttpServletRequest request) { 28 | // We don't want to wait for the next request to use the session, so as well as looking for cookies we 29 | // check for an attribute on the request. 30 | if (request.getAttribute(ATTRIBUTE_NAME) != null) { 31 | return true; 32 | } 33 | Cookie[] cookies = request.getCookies(); 34 | if (cookies != null) { 35 | for (Cookie cookie : cookies) { 36 | if (COOKIE_NAME.equals(cookie.getName())) { 37 | return true; 38 | } 39 | } 40 | } 41 | return false; 42 | } 43 | 44 | public void setWorkingSession(HttpServletRequest request, HttpServletResponse response) { 45 | // We set our own cookie here because the session is only limited to a short period of time 46 | // but we would like to use a session even after the original has expired. 47 | Cookie cookie = new Cookie(COOKIE_NAME, "true"); 48 | cookie.setHttpOnly(true); 49 | cookie.setSecure(true); 50 | cookie.setPath("/"); 51 | // Set the cookie for 1 year. 52 | // TODO This should be configurable. 53 | cookie.setMaxAge(60 * 60 * 24 * 356); 54 | response.addCookie(cookie); 55 | // Mark the current request as having a working session. 56 | request.setAttribute(ATTRIBUTE_NAME, true); 57 | } 58 | 59 | @Override 60 | public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { 61 | OAuth2AuthorizationRequest oAuth2AuthorizationRequest = sessionBased.loadAuthorizationRequest(request); 62 | if (oAuth2AuthorizationRequest != null) { 63 | return oAuth2AuthorizationRequest; 64 | } 65 | return stateBased.loadAuthorizationRequest(request); 66 | } 67 | 68 | @Override 69 | public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) { 70 | if (!hasWorkingSession(request)) { 71 | stateBased.saveAuthorizationRequest(authorizationRequest, request, response); 72 | } 73 | sessionBased.saveAuthorizationRequest(authorizationRequest, request, response); 74 | } 75 | 76 | @Override 77 | public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) { 78 | OAuth2AuthorizationRequest stateRequest = stateBased.removeAuthorizationRequest(request, response); 79 | OAuth2AuthorizationRequest sessionRequest = sessionBased.removeAuthorizationRequest(request, response); 80 | // Prioritise the one from the session if it's not null. 81 | if (sessionRequest != null) { 82 | // Mark that we got the state from the cookie. 83 | setWorkingSession(request, response); 84 | return sessionRequest; 85 | } 86 | return stateRequest; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/PathOIDCInitiationRegistrationResolver.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | import org.springframework.security.web.util.matcher.AntPathRequestMatcher; 5 | import org.springframework.util.Assert; 6 | 7 | 8 | public class PathOIDCInitiationRegistrationResolver implements OIDCInitiationRegistrationResolver { 9 | 10 | private static final String REGISTRATION_ID_URI_VARIABLE_NAME = "registrationId"; 11 | 12 | private final AntPathRequestMatcher authorizationRequestMatcher; 13 | 14 | public PathOIDCInitiationRegistrationResolver(String authorizationRequestBaseUri) { 15 | Assert.hasText(authorizationRequestBaseUri, "authorizationRequestBaseUri cannot be empty"); 16 | this.authorizationRequestMatcher = new AntPathRequestMatcher( 17 | authorizationRequestBaseUri + "/{" + REGISTRATION_ID_URI_VARIABLE_NAME + "}"); 18 | } 19 | 20 | @Override 21 | public String resolve(HttpServletRequest request) { 22 | if (this.authorizationRequestMatcher.matches(request)) { 23 | return this.authorizationRequestMatcher 24 | .extractUriTemplateVariables(request).get(REGISTRATION_ID_URI_VARIABLE_NAME); 25 | } 26 | return null; 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/StateAuthorizationRedirectHandler.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web; 2 | 3 | import com.fasterxml.jackson.core.io.JsonStringEncoder; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; 7 | import uk.ac.ox.ctl.lti13.utils.StringReader; 8 | 9 | import jakarta.servlet.http.HttpServletRequest; 10 | import jakarta.servlet.http.HttpServletResponse; 11 | import java.io.IOException; 12 | import java.io.PrintWriter; 13 | 14 | /** 15 | * @see StateCheckingAuthenticationSuccessHandler 16 | */ 17 | public class StateAuthorizationRedirectHandler implements AuthorizationRedirectHandler { 18 | 19 | private final Logger logger = LoggerFactory.getLogger(StateAuthorizationRedirectHandler.class); 20 | 21 | private final JsonStringEncoder encoder = JsonStringEncoder.getInstance(); 22 | private final String htmlTemplate; 23 | 24 | private String name = "/uk/ac/ox/ctl/lti13/step-1-redirect.html"; 25 | 26 | public StateAuthorizationRedirectHandler() { 27 | try { 28 | htmlTemplate = StringReader.readString(getClass().getResourceAsStream(name)); 29 | } catch (IOException e) { 30 | throw new IllegalStateException("Failed to read "+ name, e); 31 | } 32 | } 33 | 34 | public void setName(String name) { 35 | this.name = name; 36 | } 37 | 38 | /** 39 | * This sends the user off, but before that it saves data in the user's browser's sessionStorage so that 40 | * when they come back we can check that noting malicious is going on. 41 | */ 42 | public void sendRedirect(HttpServletRequest request, HttpServletResponse response, OAuth2AuthorizationRequest authorizationRequest) throws IOException { 43 | String url = authorizationRequest.getAuthorizationRequestUri(); 44 | if (response.isCommitted()) { 45 | logger.debug("Response has already been committed. Unable to redirect to {}", url); 46 | return; 47 | } 48 | String state = new String(encoder.quoteAsString(authorizationRequest.getState())); 49 | // TODO We should be using a LTI Specific Auth request here. 50 | String nonce = new String(encoder.quoteAsString((String)authorizationRequest.getAdditionalParameters().get("nonce"))); 51 | response.setContentType("text/html;charset=UTF-8"); 52 | PrintWriter writer = response.getWriter(); 53 | final String body = htmlTemplate 54 | .replaceFirst("@@state@@", state) 55 | .replaceFirst("@@url@@", url) 56 | .replaceFirst("@@nonce@@", nonce); 57 | writer.append(body); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/StateAuthorizationRequestRepository.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web; 2 | 3 | import com.google.common.cache.Cache; 4 | import com.google.common.cache.CacheBuilder; 5 | import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; 6 | import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; 7 | import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; 8 | import org.springframework.util.Assert; 9 | 10 | import jakarta.servlet.http.HttpServletRequest; 11 | import jakarta.servlet.http.HttpServletResponse; 12 | import java.time.Duration; 13 | import java.util.function.BiConsumer; 14 | 15 | /** 16 | * This store uses the state value in the initial request to lookup the request when the client 17 | * returns. Normally this would expose the login to a CSRF attack but we also check that the 18 | * remote IP address is the same in an attempt to limit this. 19 | * 20 | * @see org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository 21 | */ 22 | public final class StateAuthorizationRequestRepository implements AuthorizationRequestRepository { 23 | 24 | /** 25 | * The key we use to store the remote IP in attributes. 26 | */ 27 | public static final String REMOTE_IP = "remote_ip"; 28 | 29 | // The cache of request in flight 30 | private final Cache store; 31 | 32 | // Should we limit the login to a single IP address. 33 | // This may cause problems when users are on mobile devices and subsequent requests don't use the same IP address. 34 | private boolean limitIpAddress = true; 35 | 36 | // The handler to be called when an IP address mismatch is detected, by default this doesn't do anything. 37 | // It is expected that this will do something like logging of the mismatch. 38 | private BiConsumer ipMismatchHandler = (a,b) -> {}; 39 | 40 | public StateAuthorizationRequestRepository(Duration duration) { 41 | store = CacheBuilder.newBuilder() 42 | .expireAfterAccess(duration) 43 | .build(); 44 | } 45 | 46 | public void setLimitIpAddress(boolean limitIpAddress) { 47 | this.limitIpAddress = limitIpAddress; 48 | } 49 | 50 | public void setIpMismatchHandler(BiConsumer ipMismatchHandler) { 51 | this.ipMismatchHandler = ipMismatchHandler; 52 | } 53 | 54 | @Override 55 | public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { 56 | Assert.notNull(request, "request cannot be null"); 57 | String stateParameter = request.getParameter(OAuth2ParameterNames.STATE); 58 | if (stateParameter == null) { 59 | return null; 60 | } 61 | OAuth2AuthorizationRequest oAuth2AuthorizationRequest = store.getIfPresent(stateParameter); 62 | if (oAuth2AuthorizationRequest != null) { 63 | // The IP address from the initial request 64 | String initialIp = oAuth2AuthorizationRequest.getAttribute(REMOTE_IP); 65 | if (initialIp != null) { 66 | String requestIp = request.getRemoteAddr(); 67 | if (!initialIp.equals(request.getRemoteAddr())) { 68 | // Even if we aren't limiting IP address we call the consumer. 69 | ipMismatchHandler.accept(initialIp, requestIp); 70 | if (limitIpAddress) { 71 | return null; 72 | } 73 | } 74 | } 75 | } 76 | return oAuth2AuthorizationRequest; 77 | } 78 | 79 | @Override 80 | public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) { 81 | Assert.notNull(request, "request cannot be null"); 82 | Assert.notNull(response, "response cannot be null"); 83 | if (authorizationRequest == null) { 84 | this.removeAuthorizationRequest(request, response); 85 | return; 86 | } 87 | String state = authorizationRequest.getState(); 88 | Assert.hasText(state, "authorizationRequest.state cannot be empty"); 89 | store.put(state, authorizationRequest); 90 | } 91 | 92 | @Override 93 | public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) { 94 | OAuth2AuthorizationRequest authorizationRequest = loadAuthorizationRequest(request); 95 | if (authorizationRequest != null) { 96 | String stateParameter = request.getParameter(OAuth2ParameterNames.STATE); 97 | store.invalidate(stateParameter); 98 | } 99 | return authorizationRequest; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/client/lti/web/StateCheckingAuthenticationSuccessHandler.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web; 2 | 3 | /* 4 | * Copyright 2002-2016 the original author or authors. 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * https://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | import org.springframework.security.core.Authentication; 20 | import org.springframework.security.oauth2.core.oidc.user.OidcUser; 21 | import org.springframework.security.web.WebAttributes; 22 | import org.springframework.security.web.authentication.AbstractAuthenticationTargetUrlRequestHandler; 23 | import org.springframework.security.web.authentication.AuthenticationSuccessHandler; 24 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.authentication.OidcAuthenticationToken; 25 | import uk.ac.ox.ctl.lti13.utils.StringReader; 26 | 27 | import jakarta.servlet.ServletException; 28 | import jakarta.servlet.http.HttpServletRequest; 29 | import jakarta.servlet.http.HttpServletResponse; 30 | import jakarta.servlet.http.HttpSession; 31 | import java.io.IOException; 32 | import java.io.PrintWriter; 33 | 34 | /** 35 | * This is needed so that we can pass the state value to the client(browser) to allow it to check if it matches the 36 | * value saved at the start of the login. 37 | * 38 | * @author Matthew Buckett 39 | * @see StateAuthorizationRedirectHandler 40 | */ 41 | public class StateCheckingAuthenticationSuccessHandler extends 42 | AbstractAuthenticationTargetUrlRequestHandler implements 43 | AuthenticationSuccessHandler { 44 | 45 | private final OptimisticAuthorizationRequestRepository authorizationRequestRepository; 46 | private final String htmlTemplate; 47 | 48 | private String name = "/uk/ac/ox/ctl/lti13/step-3-redirect.html"; 49 | 50 | /** 51 | * @param authorizationRequestRepository The repository holding authorization requests 52 | */ 53 | public StateCheckingAuthenticationSuccessHandler(OptimisticAuthorizationRequestRepository authorizationRequestRepository) { 54 | this.authorizationRequestRepository = authorizationRequestRepository; 55 | try { 56 | htmlTemplate = StringReader.readString(getClass().getResourceAsStream(name)); 57 | } catch (IOException e) { 58 | throw new IllegalStateException("Failed to read " + name, e); 59 | } 60 | } 61 | 62 | public void setName(String name) { 63 | this.name = name; 64 | } 65 | 66 | /** 67 | * Calls the parent class {@code handle()} method to forward or redirect to the target 68 | * URL, and then calls {@code clearAuthenticationAttributes()} to remove any leftover 69 | * session data. 70 | */ 71 | public void onAuthenticationSuccess(HttpServletRequest request, 72 | HttpServletResponse response, Authentication authentication) 73 | throws IOException, ServletException { 74 | 75 | handle(request, response, authentication); 76 | clearAuthenticationAttributes(request); 77 | } 78 | 79 | protected void handle(HttpServletRequest request, HttpServletResponse response, 80 | Authentication authentication) throws IOException, ServletException { 81 | 82 | // If we got this from the Session then just redirect 83 | if (authorizationRequestRepository.hasWorkingSession(request)) { 84 | super.handle(request, response, authentication); 85 | return; 86 | } 87 | String targetUrl = determineTargetUrl(request, response, authentication); 88 | 89 | if (response.isCommitted()) { 90 | logger.debug("Response has already been committed. Unable to redirect to " 91 | + targetUrl); 92 | return; 93 | } 94 | 95 | if (!(authentication instanceof OidcAuthenticationToken oidcAuthenticationToken)) { 96 | logger.debug("Authentication should be OidcAuthenticationToken. Unable to redirect to " 97 | + targetUrl); 98 | return; 99 | } 100 | String state = oidcAuthenticationToken.getState(); 101 | String nonce = ((OidcUser)(oidcAuthenticationToken).getPrincipal()).getIdToken().getNonce(); 102 | 103 | response.setContentType("text/html;charset=UTF-8"); 104 | PrintWriter writer = response.getWriter(); 105 | writer.append(htmlTemplate 106 | .replaceFirst("@@state@@", state) 107 | .replaceFirst("@@url@@", targetUrl) 108 | .replaceFirst("@@nonce@@", nonce) 109 | ); 110 | } 111 | 112 | /** 113 | * Removes temporary authentication-related data which may have been stored in the 114 | * session during the authentication process. 115 | * @param request The HttpServletRequest to clear the authentication from. 116 | */ 117 | protected final void clearAuthenticationAttributes(HttpServletRequest request) { 118 | HttpSession session = request.getSession(false); 119 | 120 | if (session == null) { 121 | return; 122 | } 123 | 124 | session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/core/endpoint/OIDCLaunchFlowExchange.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2002-2017 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package uk.ac.ox.ctl.lti13.security.oauth2.core.endpoint; 17 | 18 | import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; 19 | import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; 20 | import org.springframework.util.Assert; 21 | 22 | /** 23 | * An "exchange" of an OAuth 2.0 Authorization Request and Response 24 | * for the authorization code grant type. 25 | * 26 | * @author Joe Grandja 27 | * @since 5.0 28 | * @see OAuth2AuthorizationRequest 29 | * @see OAuth2AuthorizationResponse 30 | */ 31 | public final class OIDCLaunchFlowExchange { 32 | // This should be our own class in the future 33 | private final OAuth2AuthorizationRequest authorizationRequest; 34 | private final OIDCLaunchFlowResponse authorizationResponse; 35 | 36 | /** 37 | * Constructs a new {@code OAuth2AuthorizationExchange} with the provided 38 | * Authorization Request and Authorization Response. 39 | * 40 | * @param authorizationRequest the {@link OAuth2AuthorizationRequest Authorization Request} 41 | * @param authorizationResponse the {@link OAuth2AuthorizationResponse Authorization Response} 42 | */ 43 | public OIDCLaunchFlowExchange(OAuth2AuthorizationRequest authorizationRequest, 44 | OIDCLaunchFlowResponse authorizationResponse) { 45 | Assert.notNull(authorizationRequest, "authorizationRequest cannot be null"); 46 | Assert.notNull(authorizationResponse, "authorizationResponse cannot be null"); 47 | this.authorizationRequest = authorizationRequest; 48 | this.authorizationResponse = authorizationResponse; 49 | } 50 | 51 | /** 52 | * Returns the {@link OAuth2AuthorizationRequest Authorization Request}. 53 | * 54 | * @return the {@link OAuth2AuthorizationRequest} 55 | */ 56 | public OAuth2AuthorizationRequest getAuthorizationRequest() { 57 | return this.authorizationRequest; 58 | } 59 | 60 | /** 61 | * Returns the {@link OIDCLaunchFlowResponse Authorization Response}. 62 | * 63 | * @return the {@link OIDCLaunchFlowResponse} 64 | */ 65 | public OIDCLaunchFlowResponse getAuthorizationResponse() { 66 | return this.authorizationResponse; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/core/endpoint/OIDCLaunchFlowResponse.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2002-2017 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package uk.ac.ox.ctl.lti13.security.oauth2.core.endpoint; 17 | 18 | import org.springframework.security.oauth2.core.OAuth2Error; 19 | import org.springframework.util.Assert; 20 | import org.springframework.util.StringUtils; 21 | 22 | /** 23 | * A representation of an IMS 1.0 Security Launch Flow response. 24 | * 25 | * @author Matthew Buckett 26 | * @see OAuth2Error 27 | * @see 5.1.1.3 Step 3: Authentication Response 28 | */ 29 | public final class OIDCLaunchFlowResponse { 30 | private String state; 31 | private String idToken; 32 | private OAuth2Error error; 33 | 34 | private OIDCLaunchFlowResponse() { 35 | } 36 | 37 | /** 38 | * Return the ID Token. 39 | * 40 | * @return the ID Token. 41 | */ 42 | public String getIdToken() { 43 | return idToken; 44 | } 45 | 46 | /** 47 | * Returns the state. 48 | * 49 | * @return the state 50 | */ 51 | public String getState() { 52 | return this.state; 53 | } 54 | 55 | /** 56 | * Returns the {@link OAuth2Error OAuth 2.0 Error} if the Authorization Request failed, otherwise {@code null}. 57 | * 58 | * @return the {@link OAuth2Error} if the Authorization Request failed, otherwise {@code null} 59 | */ 60 | public OAuth2Error getError() { 61 | return this.error; 62 | } 63 | 64 | /** 65 | * Returns {@code true} if the Authorization Request succeeded, otherwise {@code false}. 66 | * 67 | * @return {@code true} if the Authorization Request succeeded, otherwise {@code false} 68 | */ 69 | public boolean statusOk() { 70 | return !this.statusError(); 71 | } 72 | 73 | /** 74 | * Returns {@code true} if the Authorization Request failed, otherwise {@code false}. 75 | * 76 | * @return {@code true} if the Authorization Request failed, otherwise {@code false} 77 | */ 78 | public boolean statusError() { 79 | return (this.error != null && this.error.getErrorCode() != null); 80 | } 81 | 82 | /** 83 | * Returns a new {@link Builder}, initialized with the ID Token. 84 | * 85 | * @param idToken The ID Token 86 | * @return the {@link Builder} 87 | */ 88 | public static Builder success(String idToken) { 89 | Assert.hasText(idToken, "code cannot be empty"); 90 | return new Builder().idToken(idToken); 91 | } 92 | 93 | /** 94 | * Returns a new {@link Builder}, initialized with the error code. 95 | * 96 | * @param errorCode the error code 97 | * @return the {@link Builder} 98 | */ 99 | public static Builder error(String errorCode) { 100 | Assert.hasText(errorCode, "errorCode cannot be empty"); 101 | return new Builder().errorCode(errorCode); 102 | } 103 | 104 | /** 105 | * A builder for {@link OIDCLaunchFlowResponse}. 106 | */ 107 | public static class Builder { 108 | private String state; 109 | private String idToken; 110 | private String errorCode; 111 | private String errorDescription; 112 | private String errorUri; 113 | 114 | private Builder() { 115 | } 116 | 117 | /** 118 | * Sets the ID Token. 119 | * 120 | * @param idToken the ID Token. 121 | * @return the {@link Builder} 122 | */ 123 | public Builder idToken(String idToken) { 124 | this.idToken = idToken; 125 | return this; 126 | } 127 | 128 | /** 129 | * Sets the state. 130 | * 131 | * @param state the state 132 | * @return the {@link Builder} 133 | */ 134 | public Builder state(String state) { 135 | this.state = state; 136 | return this; 137 | } 138 | 139 | /** 140 | * Sets the error code. 141 | * 142 | * @param errorCode the error code 143 | * @return the {@link Builder} 144 | */ 145 | public Builder errorCode(String errorCode) { 146 | this.errorCode = errorCode; 147 | return this; 148 | } 149 | 150 | /** 151 | * Sets the error description. 152 | * 153 | * @param errorDescription the error description 154 | * @return the {@link Builder} 155 | */ 156 | public Builder errorDescription(String errorDescription) { 157 | this.errorDescription = errorDescription; 158 | return this; 159 | } 160 | 161 | /** 162 | * Sets the error uri. 163 | * 164 | * @param errorUri the error uri 165 | * @return the {@link Builder} 166 | */ 167 | public Builder errorUri(String errorUri) { 168 | this.errorUri = errorUri; 169 | return this; 170 | } 171 | 172 | /** 173 | * Builds a new {@link OIDCLaunchFlowResponse}. 174 | * 175 | * @return a {@link OIDCLaunchFlowResponse} 176 | */ 177 | public OIDCLaunchFlowResponse build() { 178 | if (StringUtils.hasText(this.idToken) && StringUtils.hasText(this.errorCode)) { 179 | throw new IllegalArgumentException("code and errorCode cannot both be set"); 180 | } 181 | 182 | OIDCLaunchFlowResponse authorizationResponse = new OIDCLaunchFlowResponse(); 183 | authorizationResponse.state = this.state; 184 | if (StringUtils.hasText(this.idToken)) { 185 | authorizationResponse.idToken = this.idToken; 186 | } else { 187 | authorizationResponse.error = new OAuth2Error( 188 | this.errorCode, this.errorDescription, this.errorUri); 189 | } 190 | return authorizationResponse; 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/security/oauth2/core/user/LtiOauth2User.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.security.oauth2.core.user; 2 | 3 | import org.springframework.security.core.GrantedAuthority; 4 | import org.springframework.security.core.authority.AuthorityUtils; 5 | import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; 6 | import org.springframework.security.oauth2.core.oidc.OidcIdToken; 7 | import org.springframework.security.oauth2.core.oidc.OidcUserInfo; 8 | import org.springframework.security.oauth2.core.oidc.user.OidcUser; 9 | import org.springframework.util.Assert; 10 | 11 | import java.io.Serializable; 12 | import java.util.Collection; 13 | import java.util.Collections; 14 | import java.util.Comparator; 15 | import java.util.LinkedHashSet; 16 | import java.util.Map; 17 | import java.util.Set; 18 | import java.util.SortedSet; 19 | import java.util.TreeSet; 20 | 21 | /** 22 | * LTI launches can happen when there isn't a user logged in. In this situation 23 | * there isn't a subject claim and so we need to support this. 24 | */ 25 | public class LtiOauth2User implements OidcUser, Serializable { 26 | 27 | public static final String ANONYMOUS = "anonymous"; 28 | 29 | private final OidcIdToken idToken; 30 | private final Set authorities; 31 | private final String nameAttributeKey; 32 | 33 | public LtiOauth2User(Collection authorities, OidcIdToken idToken) { 34 | this(authorities, idToken, IdTokenClaimNames.SUB); 35 | } 36 | /** 37 | * Constructs a {@code DefaultOAuth2User} using the provided parameters. 38 | * 39 | * @param authorities the authorities granted to the user 40 | * @param idToken the ID Token containing claims about the user 41 | */ 42 | public LtiOauth2User(Collection authorities, OidcIdToken idToken, String nameAttributeKey) { 43 | Assert.notNull(idToken, "idToken cannot be null"); 44 | Assert.hasText(nameAttributeKey, "nameAttributeKey cannot be empty"); 45 | this.authorities = (authorities != null) 46 | ? Collections.unmodifiableSet(new LinkedHashSet<>(this.sortAuthorities(authorities))) 47 | : Collections.unmodifiableSet(new LinkedHashSet<>(AuthorityUtils.NO_AUTHORITIES)); 48 | this.idToken = idToken; 49 | this.nameAttributeKey = nameAttributeKey; 50 | } 51 | 52 | private Set sortAuthorities(Collection authorities) { 53 | SortedSet sortedAuthorities = new TreeSet<>( 54 | Comparator.comparing(GrantedAuthority::getAuthority)); 55 | sortedAuthorities.addAll(authorities); 56 | return sortedAuthorities; 57 | } 58 | 59 | @Override 60 | public String getName() { 61 | String name = idToken.getClaimAsString(nameAttributeKey); 62 | return name == null ? ANONYMOUS : name; 63 | } 64 | 65 | @Override 66 | public Map getClaims() { 67 | return idToken.getClaims(); 68 | } 69 | 70 | @Override 71 | public OidcUserInfo getUserInfo() { 72 | // In the LTI launches we never do additional user lookups so we just always return null. 73 | return null; 74 | } 75 | 76 | @Override 77 | public OidcIdToken getIdToken() { 78 | return idToken; 79 | } 80 | 81 | @Override 82 | public Map getAttributes() { 83 | return idToken.getClaims(); 84 | } 85 | 86 | @Override 87 | public Collection getAuthorities() { 88 | return authorities; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/utils/KeyStoreKeyFactory.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 20013-2014 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with 5 | * the License. You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on 10 | * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the 11 | * specific language governing permissions and limitations under the License. 12 | */ 13 | 14 | package uk.ac.ox.ctl.lti13.utils; 15 | 16 | import org.springframework.core.io.Resource; 17 | 18 | import java.security.KeyFactory; 19 | import java.security.KeyPair; 20 | import java.security.KeyStore; 21 | import java.security.PublicKey; 22 | import java.security.interfaces.RSAPrivateCrtKey; 23 | import java.security.spec.RSAPublicKeySpec; 24 | 25 | /** 26 | * Factory for RSA key pairs from a JKS keystore file. User provides a {@link Resource} location of a keystore file and 27 | * the password to unlock it, and the factory grabs the keypairs from the store by name (and optionally password). 28 | * 29 | * This is taken out of the old spring-security oauth2 codebase. 30 | * 31 | * @author Dave Syer 32 | * 33 | */ 34 | public class KeyStoreKeyFactory { 35 | 36 | private Resource resource; 37 | 38 | private char[] password; 39 | 40 | private KeyStore store; 41 | 42 | private Object lock = new Object(); 43 | 44 | public KeyStoreKeyFactory(Resource resource, char[] password) { 45 | this.resource = resource; 46 | this.password = password; 47 | } 48 | 49 | public KeyPair getKeyPair(String alias) { 50 | return getKeyPair(alias, password); 51 | } 52 | 53 | public KeyPair getKeyPair(String alias, char[] password) { 54 | try { 55 | synchronized (lock) { 56 | if (store == null) { 57 | synchronized (lock) { 58 | store = KeyStore.getInstance("jks"); 59 | store.load(resource.getInputStream(), this.password); 60 | } 61 | } 62 | } 63 | RSAPrivateCrtKey key = (RSAPrivateCrtKey) store.getKey(alias, password); 64 | RSAPublicKeySpec spec = new RSAPublicKeySpec(key.getModulus(), key.getPublicExponent()); 65 | PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(spec); 66 | return new KeyPair(publicKey, key); 67 | } 68 | catch (Exception e) { 69 | throw new IllegalStateException("Cannot load keys from store: " + resource, e); 70 | } 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/main/java/uk/ac/ox/ctl/lti13/utils/StringReader.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.utils; 2 | 3 | import java.io.BufferedReader; 4 | import java.io.IOException; 5 | import java.io.InputStream; 6 | import java.io.InputStreamReader; 7 | import java.io.Reader; 8 | import java.nio.charset.Charset; 9 | import java.nio.charset.StandardCharsets; 10 | 11 | public class StringReader { 12 | 13 | /** 14 | * Read a InputStream into a String. Can use readAll() when we are on Java 9 or newer. 15 | * @param inputStream The InputStream to read from. 16 | * @throws IOException If there's problem reading from the input. 17 | * @return The String contents of the stream. 18 | */ 19 | public static String readString(InputStream inputStream) throws IOException { 20 | StringBuilder textBuilder = new StringBuilder(); 21 | try (Reader reader = new BufferedReader(new InputStreamReader 22 | (inputStream, Charset.forName(StandardCharsets.UTF_8.name())))) { 23 | char[] buffer = new char[1024]; 24 | int len; 25 | while ((len = reader.read(buffer)) != -1) { 26 | textBuilder.append(buffer, 0, len); 27 | } 28 | } 29 | return textBuilder.toString(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/resources/uk/ac/ox/ctl/lti13/step-1-redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Redirect step 1 5 | 6 | 7 | 8 |

Loading...

9 | 10 | 137 | 138 | -------------------------------------------------------------------------------- /src/main/resources/uk/ac/ox/ctl/lti13/step-3-redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Redirect step 3 5 | 6 | 7 | 8 |

Redirecting....

9 | 10 | 128 | -------------------------------------------------------------------------------- /src/test/java/uk/ac/ox/ctl/lti13/config/Lti13Configuration.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.config; 2 | 3 | import org.mockito.Mockito; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 7 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 8 | import org.springframework.security.oauth2.client.registration.ClientRegistration; 9 | import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; 10 | import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; 11 | import org.springframework.security.web.SecurityFilterChain; 12 | import org.springframework.web.client.RestOperations; 13 | import org.springframework.web.servlet.config.annotation.EnableWebMvc; 14 | import uk.ac.ox.ctl.lti13.Lti13Configurer; 15 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web.LTIAuthorizationGrantType; 16 | 17 | import java.security.KeyPair; 18 | import java.security.KeyPairGenerator; 19 | import java.security.NoSuchAlgorithmException; 20 | 21 | @Configuration 22 | @EnableWebSecurity 23 | @EnableWebMvc 24 | public class Lti13Configuration { 25 | 26 | @Bean 27 | protected SecurityFilterChain configure(HttpSecurity http) throws Exception { 28 | http.authorizeHttpRequests().anyRequest().authenticated(); 29 | Lti13Configurer lti13Configurer = new Lti13Configurer(); 30 | http.apply(lti13Configurer); 31 | return http.build(); 32 | } 33 | 34 | @Bean 35 | public KeyPair keyPair() throws NoSuchAlgorithmException { 36 | return KeyPairGenerator.getInstance("RSA").generateKeyPair(); 37 | } 38 | 39 | @Bean 40 | public RestOperations restOperations() { 41 | return Mockito.mock(RestOperations.class); 42 | } 43 | 44 | @Bean 45 | public ClientRegistrationRepository clientRegistrationRepository() { 46 | String platformUri = "https://platform.test/"; 47 | 48 | ClientRegistration client = ClientRegistration.withRegistrationId("test") 49 | .clientId("test-id") 50 | .authorizationGrantType(LTIAuthorizationGrantType.IMPLICIT) 51 | .scope("openid") 52 | .redirectUri("{baseUrl}/lti/login") 53 | .authorizationUri(platformUri+ "/auth/new") 54 | .tokenUri(platformUri+ "/access_tokens") 55 | .jwkSetUri(platformUri+ "/keys.json") 56 | .build(); 57 | return new InMemoryClientRegistrationRepository(client); 58 | } 59 | } 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/test/java/uk/ac/ox/ctl/lti13/stateful/Lti13Step1Test.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.stateful; 2 | 3 | 4 | import jakarta.servlet.http.Cookie; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.extension.ExtendWith; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers; 10 | import org.springframework.test.context.junit.jupiter.SpringExtension; 11 | import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; 12 | import org.springframework.test.context.web.WebAppConfiguration; 13 | import org.springframework.test.web.servlet.MockMvc; 14 | import org.springframework.test.web.servlet.setup.MockMvcBuilders; 15 | import org.springframework.web.context.WebApplicationContext; 16 | import uk.ac.ox.ctl.lti13.config.Lti13Configuration; 17 | 18 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 19 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 20 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern; 21 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 22 | 23 | @ExtendWith(SpringExtension.class) 24 | @WebAppConfiguration 25 | @SpringJUnitWebConfig(classes = {Lti13Configuration.class}) 26 | public class Lti13Step1Test { 27 | 28 | private MockMvc mockMvc; 29 | 30 | @Autowired 31 | private WebApplicationContext wac; 32 | 33 | @BeforeEach 34 | public void setup() { 35 | this.mockMvc = MockMvcBuilders.webAppContextSetup(wac) 36 | .apply(SecurityMockMvcConfigurers.springSecurity()) 37 | .build(); 38 | } 39 | 40 | @Test 41 | public void testSecured() throws Exception { 42 | this.mockMvc.perform(get("/")) 43 | .andExpect(status().isForbidden()); 44 | } 45 | 46 | @Test 47 | public void testStep1Unknown() throws Exception { 48 | this.mockMvc.perform(post("/lti/login_initiation/unknown")) 49 | .andExpect(status().is4xxClientError()); 50 | } 51 | 52 | @Test 53 | public void testStep1Empty() throws Exception { 54 | this.mockMvc.perform(post("/lti/login_initiation/test")) 55 | .andExpect(status().is4xxClientError()); 56 | } 57 | 58 | @Test 59 | public void testStep1Complete() throws Exception { 60 | this.mockMvc.perform(post("/lti/login_initiation/test") 61 | .param("iss", "https://test.com") 62 | .param("login_hint", "hint") 63 | .param("target_link_uri", "https://localhost/") 64 | .cookie(new Cookie("WORKING_COOKIES", "true")) 65 | ) 66 | .andExpect(status().is3xxRedirection()) 67 | // We can't test the cookie as this is done by Spring Security and not the controller 68 | .andExpect(redirectedUrlPattern("https://platform.test/auth/**")); 69 | } 70 | 71 | 72 | @Test 73 | public void testStep1NoStorageTarget() throws Exception { 74 | // There's no explicit support for the LTI storage platform so we assume that we can't use it and just 75 | // redirect 76 | this.mockMvc.perform(post("/lti/login_initiation/test") 77 | .param("iss", "https://test.com") 78 | .param("login_hint", "hint") 79 | .param("target_link_uri", "https://localhost/") 80 | ) 81 | .andExpect(status().is3xxRedirection()) 82 | .andExpect(redirectedUrlPattern("https://platform.test/auth/**")); 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /src/test/java/uk/ac/ox/ctl/lti13/stateful/Lti13Step3Test.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.stateful; 2 | 3 | import com.nimbusds.jose.JOSEException; 4 | import com.nimbusds.jose.JWSAlgorithm; 5 | import com.nimbusds.jose.JWSHeader; 6 | import com.nimbusds.jose.JWSSigner; 7 | import com.nimbusds.jose.crypto.RSASSASigner; 8 | import com.nimbusds.jose.jwk.JWKSet; 9 | import com.nimbusds.jose.jwk.KeyUse; 10 | import com.nimbusds.jose.jwk.RSAKey; 11 | import com.nimbusds.jwt.JWTClaimsSet; 12 | import com.nimbusds.jwt.SignedJWT; 13 | import jakarta.servlet.http.Cookie; 14 | import jakarta.servlet.http.HttpServletRequest; 15 | import jakarta.servlet.http.HttpServletResponse; 16 | import org.junit.jupiter.api.BeforeEach; 17 | import org.junit.jupiter.api.Test; 18 | import org.junit.jupiter.api.extension.ExtendWith; 19 | import org.springframework.beans.factory.annotation.Autowired; 20 | import org.springframework.beans.factory.annotation.Qualifier; 21 | import org.springframework.context.annotation.Bean; 22 | import org.springframework.context.annotation.Configuration; 23 | import org.springframework.http.HttpStatus; 24 | import org.springframework.http.ResponseEntity; 25 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 26 | import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; 27 | import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; 28 | import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; 29 | import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; 30 | import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers; 31 | import org.springframework.security.web.SecurityFilterChain; 32 | import org.springframework.test.context.junit.jupiter.SpringExtension; 33 | import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; 34 | import org.springframework.test.context.web.WebAppConfiguration; 35 | import org.springframework.test.web.servlet.MockMvc; 36 | import org.springframework.test.web.servlet.setup.MockMvcBuilders; 37 | import org.springframework.web.client.RestOperations; 38 | import org.springframework.web.context.WebApplicationContext; 39 | import uk.ac.ox.ctl.lti13.Lti13Configurer; 40 | import uk.ac.ox.ctl.lti13.config.Lti13Configuration; 41 | import uk.ac.ox.ctl.lti13.lti.Claims; 42 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.authentication.OidcLaunchFlowAuthenticationProvider; 43 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web.OAuth2LoginAuthenticationFilter; 44 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web.OptimisticAuthorizationRequestRepository; 45 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web.StateAuthorizationRequestRepository; 46 | 47 | import java.security.KeyPair; 48 | import java.security.interfaces.RSAPublicKey; 49 | import java.time.Duration; 50 | import java.time.Instant; 51 | import java.util.Date; 52 | import java.util.HashMap; 53 | import java.util.Map; 54 | 55 | import static org.mockito.Mockito.*; 56 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 57 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 58 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; 59 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 60 | import static uk.ac.ox.ctl.lti13.stateful.Lti13Step3Test.CustomLti13Configuration; 61 | 62 | @ExtendWith(SpringExtension.class) 63 | @WebAppConfiguration 64 | @SpringJUnitWebConfig(classes = {CustomLti13Configuration.class}) 65 | public class Lti13Step3Test { 66 | 67 | private MockMvc mockMvc; 68 | 69 | @Autowired 70 | private WebApplicationContext wac; 71 | 72 | @Autowired 73 | private RestOperations restOperations; 74 | 75 | @Autowired 76 | private KeyPair keyPair; 77 | 78 | @Autowired 79 | @Qualifier("http") 80 | private AuthorizationRequestRepository authorizationRequestRepository; 81 | 82 | 83 | @Configuration 84 | public static class CustomLti13Configuration extends Lti13Configuration { 85 | 86 | @Autowired 87 | private OptimisticAuthorizationRequestRepository authorizationRequestRepository; 88 | 89 | @Autowired 90 | private RestOperations restOperations; 91 | 92 | @Bean(name = "http") 93 | AuthorizationRequestRepository authorizationRequestRepository() { 94 | return mock(AuthorizationRequestRepository.class); 95 | } 96 | 97 | @Bean 98 | StateAuthorizationRequestRepository stateAuthorizationRequestRepository() { 99 | return new StateAuthorizationRequestRepository(Duration.ZERO); 100 | } 101 | 102 | @Bean 103 | OptimisticAuthorizationRequestRepository optmisticAuthorizationRequestRepository( 104 | @Qualifier("http") AuthorizationRequestRepository requestRepository, 105 | StateAuthorizationRequestRepository stateAuthorizationRequestRepository 106 | ) { 107 | return new OptimisticAuthorizationRequestRepository(requestRepository, stateAuthorizationRequestRepository); 108 | } 109 | 110 | @Bean 111 | protected SecurityFilterChain configure(HttpSecurity http) throws Exception { 112 | http.authorizeHttpRequests().anyRequest().authenticated(); 113 | Lti13Configurer lti13Configurer = new Lti13Configurer() { 114 | 115 | @Override 116 | protected OidcLaunchFlowAuthenticationProvider configureAuthenticationProvider(HttpSecurity http) { 117 | // This is so that we can mock out the HTTP response for the JWKs URL. 118 | OidcLaunchFlowAuthenticationProvider oidcLaunchFlowAuthenticationProvider = super.configureAuthenticationProvider(http); 119 | oidcLaunchFlowAuthenticationProvider.setRestOperations(restOperations); 120 | return oidcLaunchFlowAuthenticationProvider; 121 | } 122 | 123 | @Override 124 | protected OAuth2LoginAuthenticationFilter configureLoginFilter(ClientRegistrationRepository clientRegistrationRepository, OidcLaunchFlowAuthenticationProvider oidcLaunchFlowAuthenticationProvider, OptimisticAuthorizationRequestRepository authorizationRequestRepository) { 125 | // This is so that we can put a fake original request into the repository so that the state between 126 | // the fake request and out test request will match. 127 | OAuth2LoginAuthenticationFilter oAuth2LoginAuthenticationFilter = super.configureLoginFilter(clientRegistrationRepository, oidcLaunchFlowAuthenticationProvider, authorizationRequestRepository); 128 | // Set a custom request repository 129 | oAuth2LoginAuthenticationFilter.setAuthorizationRequestRepository( 130 | CustomLti13Configuration.this.authorizationRequestRepository 131 | ); 132 | return oAuth2LoginAuthenticationFilter; 133 | } 134 | }; 135 | http.apply(lti13Configurer); 136 | return http.build(); 137 | } 138 | } 139 | 140 | @BeforeEach 141 | public void setup() { 142 | this.mockMvc = MockMvcBuilders.webAppContextSetup(wac) 143 | .apply(SecurityMockMvcConfigurers.springSecurity()) 144 | .build(); 145 | } 146 | 147 | @Test 148 | public void testSecured() throws Exception { 149 | this.mockMvc.perform(get("/")) 150 | .andExpect(status().isForbidden()); 151 | } 152 | 153 | @Test 154 | public void testStep3SignedToken() throws Exception { 155 | JWTClaimsSet claims = createClaims().build(); 156 | 157 | OAuth2AuthorizationRequest oAuth2AuthorizationRequest = createAuthRequest().build(); 158 | 159 | when(authorizationRequestRepository.loadAuthorizationRequest(any(HttpServletRequest.class))) 160 | .thenReturn(oAuth2AuthorizationRequest); 161 | when(authorizationRequestRepository.removeAuthorizationRequest(any(HttpServletRequest.class), any(HttpServletResponse.class))) 162 | .thenReturn(oAuth2AuthorizationRequest); 163 | 164 | when(restOperations.exchange(any(), eq(String.class))) 165 | .thenReturn(new ResponseEntity<>(jwkSet().toString(), HttpStatus.OK)); 166 | mockMvc.perform(post("/lti/login").param("id_token", createJWT(claims)).param("state", "state").cookie(new Cookie("WORKING_COOKIES", "true"))) 167 | .andExpect(status().is3xxRedirection()); 168 | } 169 | 170 | @Test 171 | public void testStep3SignedTokenAnonymous() throws Exception { 172 | // When it's an anonymous request there's no subject in the claims. 173 | JWTClaimsSet claims = createClaims().subject(null).build(); 174 | 175 | OAuth2AuthorizationRequest oAuth2AuthorizationRequest = createAuthRequest().build(); 176 | 177 | when(authorizationRequestRepository.loadAuthorizationRequest(any(HttpServletRequest.class))) 178 | .thenReturn(oAuth2AuthorizationRequest); 179 | when(authorizationRequestRepository.removeAuthorizationRequest(any(HttpServletRequest.class), any(HttpServletResponse.class))) 180 | .thenReturn(oAuth2AuthorizationRequest); 181 | 182 | when(restOperations.exchange(any(), eq(String.class))) 183 | .thenReturn(new ResponseEntity<>(jwkSet().toString(), HttpStatus.OK)); 184 | mockMvc.perform(post("/lti/login").param("id_token", createJWT(claims)).param("state", "state").cookie(new Cookie("WORKING_COOKIES", "true"))) 185 | .andExpect(status().is3xxRedirection()); 186 | } 187 | 188 | @Test 189 | public void testStep3SignedTokenNoCookie() throws Exception { 190 | // Here we haven't already marked the browser as having a working session based on cookies, but we 191 | // do manage to retrieve the 192 | JWTClaimsSet claims = createClaims().build(); 193 | 194 | OAuth2AuthorizationRequest oAuth2AuthorizationRequest = createAuthRequest().build(); 195 | 196 | when(authorizationRequestRepository.loadAuthorizationRequest(any(HttpServletRequest.class))) 197 | .thenReturn(oAuth2AuthorizationRequest); 198 | when(authorizationRequestRepository.removeAuthorizationRequest(any(HttpServletRequest.class), any(HttpServletResponse.class))) 199 | .thenReturn(oAuth2AuthorizationRequest); 200 | 201 | when(restOperations.exchange(any(), eq(String.class))) 202 | .thenReturn(new ResponseEntity<>(jwkSet().toString(), HttpStatus.OK)); 203 | mockMvc.perform(post("/lti/login").param("id_token", createJWT(claims)).param("state", "state")) 204 | .andExpect(status().is3xxRedirection()) 205 | .andExpect(cookie().exists("WORKING_COOKIES")); 206 | } 207 | 208 | @Test 209 | public void testStep3WrongVersion() throws Exception { 210 | // Remove the LTI Version. 211 | JWTClaimsSet claims = createClaims().claim(Claims.LTI_VERSION, null).build(); 212 | 213 | OAuth2AuthorizationRequest oAuth2AuthorizationRequest = createAuthRequest().build(); 214 | 215 | when(authorizationRequestRepository.removeAuthorizationRequest(any(HttpServletRequest.class), any(HttpServletResponse.class))) 216 | .thenReturn(oAuth2AuthorizationRequest); 217 | 218 | when(restOperations.exchange(any(), eq(String.class))) 219 | .thenReturn(new ResponseEntity<>(jwkSet().toString(), HttpStatus.OK)); 220 | mockMvc.perform(post("/lti/login").param("id_token", createJWT(claims)).param("state", "state")) 221 | .andExpect(status().is4xxClientError()); 222 | } 223 | 224 | @Test 225 | public void testStep3Error() throws Exception { 226 | OAuth2AuthorizationRequest oAuth2AuthorizationRequest = createAuthRequest().build(); 227 | 228 | when(authorizationRequestRepository.removeAuthorizationRequest(any(HttpServletRequest.class), any(HttpServletResponse.class))) 229 | .thenReturn(oAuth2AuthorizationRequest); 230 | 231 | when(restOperations.exchange(any(), eq(String.class))) 232 | .thenReturn(new ResponseEntity<>(jwkSet().toString(), HttpStatus.OK)); 233 | // Check that when we return an actual error it gets correctly handled. 234 | mockMvc.perform(post("/lti/login").param("error", "problem").param("state", "state")) 235 | .andExpect(status().is4xxClientError()); 236 | } 237 | 238 | @Test 239 | public void testStep3Empty() throws Exception { 240 | this.mockMvc.perform(post("/lti/login")) 241 | .andExpect(status().is4xxClientError()); 242 | } 243 | 244 | private OAuth2AuthorizationRequest.Builder createAuthRequest() { 245 | Map additionalParameters = new HashMap<>(); 246 | additionalParameters.put(OAuth2ParameterNames.REGISTRATION_ID, "test"); 247 | return OAuth2AuthorizationRequest.authorizationCode() 248 | .authorizationUri("https://platform.test/auth/new") 249 | .redirectUri("https://tool.test/lti/login") 250 | .scope("openid") 251 | .state("state") 252 | .additionalParameters(additionalParameters) 253 | .clientId("test-id"); 254 | } 255 | 256 | private String createJWT(JWTClaimsSet claims) throws JOSEException { 257 | JWSHeader header = new JWSHeader(JWSAlgorithm.RS256); 258 | 259 | SignedJWT jwt = new SignedJWT(header, claims); 260 | JWSSigner signer = new RSASSASigner(keyPair.getPrivate()); 261 | jwt.sign(signer); 262 | return jwt.serialize(); 263 | } 264 | 265 | private JWTClaimsSet.Builder createClaims() { 266 | return new JWTClaimsSet.Builder() 267 | .issuer("https://platform.test") 268 | .subject("subject") 269 | .claim("scope", "openid") 270 | .audience("test-id") 271 | .issueTime(new Date()) 272 | .expirationTime(Date.from(Instant.now().plusSeconds(300))) 273 | .claim("nonce", "test-nonce") 274 | .claim(Claims.LTI_VERSION, "1.3.0") 275 | .claim(Claims.MESSAGE_TYPE, "unchecked") 276 | .claim(Claims.ROLES, "") 277 | .claim(Claims.LTI_DEPLOYMENT_ID, "1"); 278 | } 279 | 280 | private JWKSet jwkSet() { 281 | RSAKey.Builder builder = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic()) 282 | .keyUse(KeyUse.SIGNATURE) 283 | .algorithm(JWSAlgorithm.RS256) 284 | .keyID("jwt-id"); 285 | return new JWKSet(builder.build()); 286 | } 287 | 288 | 289 | } 290 | -------------------------------------------------------------------------------- /src/test/java/uk/ac/ox/ctl/lti13/stateless/Lti13Step1Test.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.stateless; 2 | 3 | 4 | import org.junit.jupiter.api.BeforeEach; 5 | import org.junit.jupiter.api.Test; 6 | import org.junit.jupiter.api.extension.ExtendWith; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers; 9 | import org.springframework.test.context.TestPropertySource; 10 | import org.springframework.test.context.junit.jupiter.SpringExtension; 11 | import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; 12 | import org.springframework.test.context.web.WebAppConfiguration; 13 | import org.springframework.test.web.servlet.MockMvc; 14 | import org.springframework.test.web.servlet.setup.MockMvcBuilders; 15 | import org.springframework.web.context.WebApplicationContext; 16 | import uk.ac.ox.ctl.lti13.config.Lti13Configuration; 17 | 18 | import static org.hamcrest.Matchers.containsString; 19 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 20 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 21 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; 22 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 23 | 24 | @ExtendWith(SpringExtension.class) 25 | @WebAppConfiguration 26 | @TestPropertySource(properties = "use.state=true") 27 | @SpringJUnitWebConfig(classes = {Lti13Configuration.class}) 28 | public class Lti13Step1Test { 29 | 30 | private MockMvc mockMvc; 31 | 32 | @Autowired 33 | private WebApplicationContext wac; 34 | 35 | @BeforeEach 36 | public void setup() { 37 | this.mockMvc = MockMvcBuilders.webAppContextSetup(wac) 38 | .apply(SecurityMockMvcConfigurers.springSecurity()) 39 | .build(); 40 | } 41 | 42 | @Test 43 | public void testSecured() throws Exception { 44 | this.mockMvc.perform(get("/")) 45 | .andExpect(status().isForbidden()); 46 | } 47 | 48 | @Test 49 | public void testStep1Unknown() throws Exception { 50 | this.mockMvc.perform(post("/lti/login_initiation/unknown")) 51 | .andExpect(status().is4xxClientError()); 52 | } 53 | 54 | @Test 55 | public void testStep1Empty() throws Exception { 56 | this.mockMvc.perform(post("/lti/login_initiation/test")) 57 | .andExpect(status().is4xxClientError()); 58 | } 59 | 60 | @Test 61 | public void testStep1Complete() throws Exception { 62 | this.mockMvc.perform(post("/lti/login_initiation/test") 63 | .param("iss", "https://test.com") 64 | .param("login_hint", "hint") 65 | .param("target_link_uri", "https://localhost/") 66 | .param("lti_storage_target", "_parent") 67 | ) 68 | .andExpect(status().isOk()) 69 | // Just check that we're putting the right content in the page. 70 | .andExpect(content().string(containsString("https://platform.test/auth/"))); 71 | } 72 | 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/test/java/uk/ac/ox/ctl/lti13/stateless/Lti13Step3Test.java: -------------------------------------------------------------------------------- 1 | package uk.ac.ox.ctl.lti13.stateless; 2 | 3 | import com.nimbusds.jose.JOSEException; 4 | import com.nimbusds.jose.JWSAlgorithm; 5 | import com.nimbusds.jose.JWSHeader; 6 | import com.nimbusds.jose.JWSSigner; 7 | import com.nimbusds.jose.crypto.RSASSASigner; 8 | import com.nimbusds.jose.jwk.JWKSet; 9 | import com.nimbusds.jose.jwk.KeyUse; 10 | import com.nimbusds.jose.jwk.RSAKey; 11 | import com.nimbusds.jwt.JWTClaimsSet; 12 | import com.nimbusds.jwt.SignedJWT; 13 | import jakarta.servlet.http.HttpServletRequest; 14 | import jakarta.servlet.http.HttpServletResponse; 15 | import org.junit.jupiter.api.BeforeEach; 16 | import org.junit.jupiter.api.Test; 17 | import org.junit.jupiter.api.extension.ExtendWith; 18 | import org.springframework.beans.factory.annotation.Autowired; 19 | import org.springframework.context.annotation.Bean; 20 | import org.springframework.context.annotation.Configuration; 21 | import org.springframework.http.HttpStatus; 22 | import org.springframework.http.ResponseEntity; 23 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 24 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 25 | import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; 26 | import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; 27 | import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; 28 | import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers; 29 | import org.springframework.security.web.SecurityFilterChain; 30 | import org.springframework.test.context.junit.jupiter.SpringExtension; 31 | import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; 32 | import org.springframework.test.context.web.WebAppConfiguration; 33 | import org.springframework.test.web.servlet.MockMvc; 34 | import org.springframework.test.web.servlet.setup.MockMvcBuilders; 35 | import org.springframework.web.client.RestOperations; 36 | import org.springframework.web.context.WebApplicationContext; 37 | import uk.ac.ox.ctl.lti13.Lti13Configurer; 38 | import uk.ac.ox.ctl.lti13.config.Lti13Configuration; 39 | import uk.ac.ox.ctl.lti13.lti.Claims; 40 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.authentication.OidcLaunchFlowAuthenticationProvider; 41 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web.OAuth2LoginAuthenticationFilter; 42 | import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.web.OptimisticAuthorizationRequestRepository; 43 | 44 | import java.security.KeyPair; 45 | import java.security.interfaces.RSAPublicKey; 46 | import java.time.Instant; 47 | import java.util.Date; 48 | import java.util.HashMap; 49 | import java.util.Map; 50 | 51 | import static org.hamcrest.Matchers.containsString; 52 | import static org.mockito.Mockito.*; 53 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 54 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; 55 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 56 | import static uk.ac.ox.ctl.lti13.lti.Claims.TARGET_LINK_URI; 57 | 58 | @ExtendWith(SpringExtension.class) 59 | @WebAppConfiguration 60 | @SpringJUnitWebConfig(classes = {Lti13Step3Test.CustomLti13Configuration.class}) 61 | public class Lti13Step3Test { 62 | 63 | private MockMvc mockMvc; 64 | 65 | @Autowired 66 | private WebApplicationContext wac; 67 | 68 | @Autowired 69 | private RestOperations restOperations; 70 | 71 | @Autowired 72 | private KeyPair keyPair; 73 | 74 | @Autowired 75 | private OptimisticAuthorizationRequestRepository authorizationRequestRepository; 76 | 77 | 78 | @Configuration 79 | @EnableWebSecurity 80 | public static class CustomLti13Configuration extends Lti13Configuration { 81 | 82 | @Autowired 83 | private OptimisticAuthorizationRequestRepository authorizationRequestRepository; 84 | 85 | @Autowired 86 | private RestOperations restOperations; 87 | 88 | @Bean 89 | OptimisticAuthorizationRequestRepository authorizationRequestRepository() { 90 | return mock(OptimisticAuthorizationRequestRepository.class); 91 | } 92 | 93 | @Bean 94 | protected SecurityFilterChain configure(HttpSecurity http) throws Exception { 95 | http.authorizeHttpRequests().anyRequest().authenticated(); 96 | Lti13Configurer lti13Configurer = new Lti13Configurer() { 97 | 98 | @Override 99 | protected OidcLaunchFlowAuthenticationProvider configureAuthenticationProvider(HttpSecurity http) { 100 | // This is so that we can mock out the HTTP response for the JWKs URL. 101 | OidcLaunchFlowAuthenticationProvider oidcLaunchFlowAuthenticationProvider = super.configureAuthenticationProvider(http); 102 | oidcLaunchFlowAuthenticationProvider.setRestOperations(restOperations); 103 | return oidcLaunchFlowAuthenticationProvider; 104 | } 105 | 106 | @Override 107 | protected OAuth2LoginAuthenticationFilter configureLoginFilter(ClientRegistrationRepository clientRegistrationRepository, OidcLaunchFlowAuthenticationProvider oidcLaunchFlowAuthenticationProvider, OptimisticAuthorizationRequestRepository authorizationRequestRepository) { 108 | // This is so that we can put a fake original request into the repository so that the state between 109 | // the fake request and out test request will match. 110 | OAuth2LoginAuthenticationFilter oAuth2LoginAuthenticationFilter = super.configureLoginFilter(clientRegistrationRepository, oidcLaunchFlowAuthenticationProvider, authorizationRequestRepository); 111 | // Set a custom request repository 112 | oAuth2LoginAuthenticationFilter.setAuthorizationRequestRepository( 113 | CustomLti13Configuration.this.authorizationRequestRepository 114 | ); 115 | return oAuth2LoginAuthenticationFilter; 116 | } 117 | }; 118 | http.apply(lti13Configurer); 119 | return http.build(); 120 | } 121 | } 122 | 123 | @BeforeEach 124 | public void setup() { 125 | this.mockMvc = MockMvcBuilders.webAppContextSetup(wac) 126 | .apply(SecurityMockMvcConfigurers.springSecurity()) 127 | .build(); 128 | } 129 | 130 | @Test 131 | public void testSecured() throws Exception { 132 | this.mockMvc.perform(get("/")) 133 | .andExpect(status().isForbidden()); 134 | } 135 | 136 | @Test 137 | public void testStep3SignedToken() throws Exception { 138 | JWTClaimsSet claims = createClaims().build(); 139 | 140 | OAuth2AuthorizationRequest oAuth2AuthorizationRequest = createAuthRequest().build(); 141 | 142 | when(authorizationRequestRepository.removeAuthorizationRequest(any(HttpServletRequest.class), any(HttpServletResponse.class))) 143 | .thenReturn(oAuth2AuthorizationRequest); 144 | 145 | when(restOperations.exchange(any(), eq(String.class))) 146 | .thenReturn(new ResponseEntity<>(jwkSet().toString(), HttpStatus.OK)); 147 | mockMvc.perform(get("/lti/login").param("id_token", createJWT(claims)).param("state", "state-123-abc")) 148 | // Check that we have correct URL and state in the HTML 149 | .andExpect(content().string(containsString("state-123-abc"))) 150 | .andExpect(content().string(containsString("https://target.link/uri"))); 151 | } 152 | 153 | @Test 154 | public void testStep3SignedTokenAnon() throws Exception { 155 | // When it's an anonymous request there's no subject in the claims. 156 | JWTClaimsSet claims = createClaims().subject(null).build(); 157 | 158 | OAuth2AuthorizationRequest oAuth2AuthorizationRequest = createAuthRequest().build(); 159 | 160 | when(authorizationRequestRepository.removeAuthorizationRequest(any(HttpServletRequest.class), any(HttpServletResponse.class))) 161 | .thenReturn(oAuth2AuthorizationRequest); 162 | 163 | when(restOperations.exchange(any(), eq(String.class))) 164 | .thenReturn(new ResponseEntity<>(jwkSet().toString(), HttpStatus.OK)); 165 | mockMvc.perform(get("/lti/login").param("id_token", createJWT(claims)).param("state", "state-123-abc")) 166 | // Check that we have correct URL and state in the HTML 167 | .andExpect(content().string(containsString("state-123-abc"))) 168 | .andExpect(content().string(containsString("https://target.link/uri"))); 169 | } 170 | 171 | @Test 172 | public void testStep3WrongVersion() throws Exception { 173 | // Remove the LTI Version. 174 | JWTClaimsSet claims = createClaims().claim(Claims.LTI_VERSION, null).build(); 175 | 176 | OAuth2AuthorizationRequest oAuth2AuthorizationRequest = createAuthRequest().build(); 177 | 178 | when(authorizationRequestRepository.removeAuthorizationRequest(any(HttpServletRequest.class), any(HttpServletResponse.class))) 179 | .thenReturn(oAuth2AuthorizationRequest); 180 | 181 | when(restOperations.exchange(any(), eq(String.class))) 182 | .thenReturn(new ResponseEntity<>(jwkSet().toString(), HttpStatus.OK)); 183 | mockMvc.perform(get("/lti/login").param("id_token", createJWT(claims)).param("state", "state")) 184 | .andExpect(status().is4xxClientError()); 185 | } 186 | 187 | @Test 188 | public void testStep3Empty() throws Exception { 189 | this.mockMvc.perform(get("/lti/login")) 190 | .andExpect(status().is4xxClientError()); 191 | } 192 | 193 | private OAuth2AuthorizationRequest.Builder createAuthRequest() { 194 | Map additionalParameters = new HashMap<>(); 195 | additionalParameters.put(OAuth2ParameterNames.REGISTRATION_ID, "test"); 196 | return OAuth2AuthorizationRequest.authorizationCode() 197 | .authorizationUri("https://platform.test/auth/new") 198 | .redirectUri("https://tool.test/lti/login") 199 | .scope("openid") 200 | .state("state-123-abc") 201 | .additionalParameters(additionalParameters) 202 | .clientId("test-id"); 203 | } 204 | 205 | private String createJWT(JWTClaimsSet claims) throws JOSEException { 206 | JWSHeader header = new JWSHeader(JWSAlgorithm.RS256); 207 | 208 | SignedJWT jwt = new SignedJWT(header, claims); 209 | JWSSigner signer = new RSASSASigner(keyPair.getPrivate()); 210 | jwt.sign(signer); 211 | return jwt.serialize(); 212 | } 213 | 214 | private JWTClaimsSet.Builder createClaims() { 215 | return new JWTClaimsSet.Builder() 216 | .issuer("https://platform.test") 217 | .subject("subject") 218 | .claim("scope", "openid") 219 | .audience("test-id") 220 | .issueTime(new Date()) 221 | .expirationTime(Date.from(Instant.now().plusSeconds(300))) 222 | .claim("nonce", "test-nonce") 223 | .claim(Claims.LTI_VERSION, "1.3.0") 224 | .claim(Claims.MESSAGE_TYPE, "unchecked") 225 | .claim(Claims.ROLES, "") 226 | .claim(TARGET_LINK_URI, "https://target.link/uri") 227 | .claim(Claims.LTI_DEPLOYMENT_ID, "1"); 228 | } 229 | 230 | private JWKSet jwkSet() { 231 | RSAKey.Builder builder = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic()) 232 | .keyUse(KeyUse.SIGNATURE) 233 | .algorithm(JWSAlgorithm.RS256) 234 | .keyID("jwt-id"); 235 | return new JWKSet(builder.build()); 236 | } 237 | 238 | 239 | } 240 | --------------------------------------------------------------------------------