├── .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 | [](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 | *
37 | *
{@link ClientRegistrationRepository}
38 | *
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 extends GrantedAuthority> 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 extends GrantedAuthority> 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 extends GrantedAuthority> 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 extends GrantedAuthority> 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 extends GrantedAuthority> 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 extends GrantedAuthority> 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 extends GrantedAuthority> 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 |