From 572f6e148f6ce7bdb289b9189a4f4336782678e8 Mon Sep 17 00:00:00 2001
From: Andreas Svanberg <andreass@dsv.su.se>
Date: Mon, 24 Mar 2025 22:38:21 +0100
Subject: [PATCH] Use OAuth 2.0 Token Introspection during log in

Currently, it uses an endpoint similar to OpenID Connect UserInfo but with some differences. The endpoint does not require the "openid" scope for example. There is an ongoing effort to replace the OAuth 2.0 authorization server with a more standard compliant one which would break the endpoint (since it would require the "openid" scope). It is currently not possible to request the "openid" scope to future-proof since Spring would act differently if that scope is present and assume full OpenID Connect. That leads to requiring an id token to have been issued which the current authorization server does not do.

To get around this the implementation is changed to use a standard compliant Token Introspection endpoint to get access to the subject of the access token (which is the only part that's necessary right now). Since the endpoint is standard compliant it will work with any future authorization server.

It may be necessary to run "docker compose up --build" to get the latest version of the Toker containers.
---
 compose-branch-deploy.yaml                    |  2 +-
 ...enIntrospectionRequestEntityConverter.java | 42 +++++++++++++++++++
 .../dsv/scipro/war/WicketConfiguration.java   | 16 +++++++
 war/src/main/resources/application.properties |  2 +-
 4 files changed, 60 insertions(+), 2 deletions(-)
 create mode 100644 war/src/main/java/se/su/dsv/scipro/war/TokenIntrospectionRequestEntityConverter.java

diff --git a/compose-branch-deploy.yaml b/compose-branch-deploy.yaml
index 05ba4bd181..68694cc0b6 100644
--- a/compose-branch-deploy.yaml
+++ b/compose-branch-deploy.yaml
@@ -15,7 +15,7 @@ services:
       - JDBC_DATABASE_PASSWORD=scipro
       - OAUTH2_AUTHORIZATION_URI=https://oauth2-${VHOST}/authorize
       - OAUTH2_TOKEN_URI=https://oauth2-${VHOST}/exchange
-      - OAUTH2_USER_INFO_URI=https://oauth2-${VHOST}/verify
+      - OAUTH2_USER_INFO_URI=https://oauth2-${VHOST}/introspect
       - OAUTH2_CLIENT_ID=scipro_client
       - OAUTH2_CLIENT_SECRET=scipro_secret
       - OAUTH2_RESOURCE_SERVER_ID=scipro_api_client
diff --git a/war/src/main/java/se/su/dsv/scipro/war/TokenIntrospectionRequestEntityConverter.java b/war/src/main/java/se/su/dsv/scipro/war/TokenIntrospectionRequestEntityConverter.java
new file mode 100644
index 0000000000..7b5cfa9e96
--- /dev/null
+++ b/war/src/main/java/se/su/dsv/scipro/war/TokenIntrospectionRequestEntityConverter.java
@@ -0,0 +1,42 @@
+package se.su.dsv.scipro.war;
+
+import java.net.URI;
+import java.util.Collections;
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.RequestEntity;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.util.UriComponentsBuilder;
+
+public class TokenIntrospectionRequestEntityConverter implements Converter<OAuth2UserRequest, RequestEntity<?>> {
+
+    private static final MediaType FORM_URL_ENCODED = MediaType.valueOf(
+        MediaType.APPLICATION_FORM_URLENCODED_VALUE + ";charset=UTF-8"
+    );
+
+    @Override
+    public RequestEntity<?> convert(OAuth2UserRequest userRequest) {
+        ClientRegistration clientRegistration = userRequest.getClientRegistration();
+
+        URI uri = UriComponentsBuilder.fromUriString(
+            clientRegistration.getProviderDetails().getUserInfoEndpoint().getUri()
+        )
+            .build()
+            .toUri();
+
+        HttpHeaders headers = new HttpHeaders();
+        headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret());
+        headers.setAccept(Collections.singletonList(MediaType.ALL));
+        headers.setContentType(FORM_URL_ENCODED);
+
+        MultiValueMap<String, String> formParameters = new LinkedMultiValueMap<>();
+        formParameters.add(OAuth2ParameterNames.TOKEN, userRequest.getAccessToken().getTokenValue());
+        return new RequestEntity<>(formParameters, headers, HttpMethod.POST, uri);
+    }
+}
diff --git a/war/src/main/java/se/su/dsv/scipro/war/WicketConfiguration.java b/war/src/main/java/se/su/dsv/scipro/war/WicketConfiguration.java
index 90b0b70a61..064728fcbc 100644
--- a/war/src/main/java/se/su/dsv/scipro/war/WicketConfiguration.java
+++ b/war/src/main/java/se/su/dsv/scipro/war/WicketConfiguration.java
@@ -14,6 +14,7 @@ import org.springframework.context.annotation.Configuration;
 import org.springframework.core.annotation.Order;
 import org.springframework.security.config.Customizer;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
 import org.springframework.security.web.SecurityFilterChain;
 import se.su.dsv.scipro.SciProApplication;
 import se.su.dsv.scipro.crosscutting.ForwardPhase2Feedback;
@@ -67,6 +68,21 @@ public class WicketConfiguration {
         return http.build();
     }
 
+    // Stop gap measure to switch to Token Introspection instead of OIDC UserInfo
+    // endpoint. This is necessary because the UserInfo endpoint will in soon require
+    // the "openid" scope, which is not granted to our clients. Unfortunately we can't
+    // request the scope because that makes Spring require an id token in the token
+    // exchange which is not granted at the moment.
+    //
+    // Once a new authorization server is in place we can remove this bean and use
+    // straight up id tokens with "openid" scope.
+    @Bean
+    public DefaultOAuth2UserService defaultOAuth2UserService() {
+        DefaultOAuth2UserService defaultOAuth2UserService = new DefaultOAuth2UserService();
+        defaultOAuth2UserService.setRequestEntityConverter(new TokenIntrospectionRequestEntityConverter());
+        return defaultOAuth2UserService;
+    }
+
     @Bean
     public CurrentUserFromSpringSecurity currentUserFromSpringSecurity(
         UserService userService,
diff --git a/war/src/main/resources/application.properties b/war/src/main/resources/application.properties
index 0df6b375e6..7b0b3f2105 100644
--- a/war/src/main/resources/application.properties
+++ b/war/src/main/resources/application.properties
@@ -22,7 +22,7 @@ spring.security.oauth2.resourceserver.opaquetoken.client-secret=${OAUTH2_RESOURC
 spring.security.oauth2.resourceserver.opaquetoken.introspection-uri=${OAUTH2_RESOURCE_SERVER_INTROSPECTION_URI:http://localhost:59733/introspect}
 
 # Log in via local OAuth 2 authorization server
-spring.security.oauth2.client.provider.docker.user-info-uri=${OAUTH2_USER_INFO_URI:http://localhost:59734/verify}
+spring.security.oauth2.client.provider.docker.user-info-uri=${OAUTH2_USER_INFO_URI:http://localhost:59734/introspect}
 spring.security.oauth2.client.provider.docker.user-name-attribute=sub
 spring.security.oauth2.client.provider.docker.token-uri=${OAUTH2_TOKEN_URI:http://localhost:59734/exchange}
 spring.security.oauth2.client.provider.docker.authorization-uri=${OAUTH2_AUTHORIZATION_URI:http://localhost:59734/authorize}