You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by di...@apache.org on 2026/08/02 02:54:26 UTC

(airavata) branch refactor-single-module updated: Filling additional user info through user_info endpoint

This is an automated email from the ASF dual-hosted git repository.

DImuthuUpe pushed a commit to branch refactor-single-module
in repository https://gitbox.apache.org/repos/asf/airavata.git


The following commit(s) were added to refs/heads/refactor-single-module by this push:
     new 714889e594 Filling additional user info through user_info endpoint
714889e594 is described below

commit 714889e594970ea00b06ae2b3e1906d52b38060f
Author: DImuthuUpe <di...@gmail.com>
AuthorDate: Sat Aug 1 22:54:20 2026 -0400

    Filling additional user info through user_info endpoint
---
 .../server/auth/CILogonUserInfoClient.java         | 63 ++++++++++++++++++++++
 .../airavata/server/auth/SecurityConfig.java       | 19 +++++--
 .../auth/UserRoleOpaqueTokenIntrospector.java      | 27 +++++++---
 src/main/resources/application.properties          |  1 +
 4 files changed, 98 insertions(+), 12 deletions(-)

diff --git a/src/main/java/org/apache/airavata/server/auth/CILogonUserInfoClient.java b/src/main/java/org/apache/airavata/server/auth/CILogonUserInfoClient.java
new file mode 100644
index 0000000000..19e50700d5
--- /dev/null
+++ b/src/main/java/org/apache/airavata/server/auth/CILogonUserInfoClient.java
@@ -0,0 +1,63 @@
+/**
+*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+package org.apache.airavata.server.auth;
+
+import java.util.Map;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.http.HttpHeaders;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClientException;
+
+/**
+ * Fetches profile claims (email, name, etc.) from CILogon's userinfo endpoint.
+ * CILogon's token introspection response only carries a small, fixed claim set
+ * (RFC 7662 basics like {@code active}/{@code scope}/{@code sub}); the fuller
+ * profile is only available from userinfo.
+ */
+public class CILogonUserInfoClient {
+
+    private static final Log LOG = LogFactory.getLog(CILogonUserInfoClient.class);
+
+    private final RestClient restClient;
+    private final String userInfoUri;
+
+    public CILogonUserInfoClient(RestClient restClient, String userInfoUri) {
+        this.restClient = restClient;
+        this.userInfoUri = userInfoUri;
+    }
+
+    /** Returns the userinfo claims for the given access token, or an empty map if the call fails. */
+    @SuppressWarnings("unchecked")
+    public Map<String, Object> fetchUserInfo(String accessToken) {
+        try {
+            Map<String, Object> body = restClient
+                    .get()
+                    .uri(userInfoUri)
+                    .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
+                    .retrieve()
+                    .body(Map.class);
+            return body != null ? body : Map.of();
+        } catch (RestClientException ex) {
+            LOG.warn("Failed to fetch CILogon userinfo; proceeding without profile claims", ex);
+            return Map.of();
+        }
+    }
+}
diff --git a/src/main/java/org/apache/airavata/server/auth/SecurityConfig.java b/src/main/java/org/apache/airavata/server/auth/SecurityConfig.java
index 82ca105039..9a3da05442 100644
--- a/src/main/java/org/apache/airavata/server/auth/SecurityConfig.java
+++ b/src/main/java/org/apache/airavata/server/auth/SecurityConfig.java
@@ -28,17 +28,21 @@ import org.springframework.security.config.http.SessionCreationPolicy;
 import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;
 import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector;
 import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.web.client.RestClient;
 
 /**
  * Validates bearer tokens issued by CILogon and populates the security context
- * with authorities resolved via {@link UserRoleOpaqueTokenIntrospector} (looked
- * up by username, not trusted from the token), so that {@code @PreAuthorize(...)}
- * works on service methods.
+ * with profile claims and authorities resolved via {@link UserRoleOpaqueTokenIntrospector}
+ * (authorities looked up by username, not trusted from the token), so that
+ * {@code @PreAuthorize(...)} works on service methods and controllers can read
+ * the caller's email/name off the principal.
  *
  * <p>CILogon access tokens for standard (non "Full Service") OAuth clients are
  * opaque strings rather than self-contained JWTs, so tokens are validated via
  * CILogon's introspection endpoint (RFC 7662) rather than by decoding a JWT
- * locally against a JWK set.
+ * locally against a JWK set; profile attributes come from a second call to
+ * CILogon's userinfo endpoint, since introspection alone only returns a small,
+ * fixed claim set.
  *
  * <p>The filter chain itself permits all requests — enforcement happens at the
  * method level via {@code @PreAuthorize}, so unannotated endpoints remain
@@ -49,6 +53,7 @@ import org.springframework.security.web.SecurityFilterChain;
 public class SecurityConfig {
 
     private final String introspectionUri;
+    private final String userInfoUri;
     private final String clientId;
     private final String clientSecret;
     private final UserRoleLookupService userRoleLookupService;
@@ -56,10 +61,13 @@ public class SecurityConfig {
     public SecurityConfig(
             @Value("${airavata.security.cilogon.introspection-uri:https://cilogon.org/oauth2/introspect}")
                     String introspectionUri,
+            @Value("${airavata.security.cilogon.userinfo-uri:https://cilogon.org/oauth2/userinfo}")
+                    String userInfoUri,
             @Value("${airavata.security.cilogon.client-id}") String clientId,
             @Value("${airavata.security.cilogon.client-secret}") String clientSecret,
             UserRoleLookupService userRoleLookupService) {
         this.introspectionUri = introspectionUri;
+        this.userInfoUri = userInfoUri;
         this.clientId = clientId;
         this.clientSecret = clientSecret;
         this.userRoleLookupService = userRoleLookupService;
@@ -71,7 +79,8 @@ public class SecurityConfig {
                 .clientId(clientId)
                 .clientSecret(clientSecret)
                 .build();
-        return new UserRoleOpaqueTokenIntrospector(delegate, userRoleLookupService);
+        CILogonUserInfoClient userInfoClient = new CILogonUserInfoClient(RestClient.create(), userInfoUri);
+        return new UserRoleOpaqueTokenIntrospector(delegate, userRoleLookupService, userInfoClient);
     }
 
     @Bean
diff --git a/src/main/java/org/apache/airavata/server/auth/UserRoleOpaqueTokenIntrospector.java b/src/main/java/org/apache/airavata/server/auth/UserRoleOpaqueTokenIntrospector.java
index 02923ae1b9..2d703a910f 100644
--- a/src/main/java/org/apache/airavata/server/auth/UserRoleOpaqueTokenIntrospector.java
+++ b/src/main/java/org/apache/airavata/server/auth/UserRoleOpaqueTokenIntrospector.java
@@ -20,6 +20,8 @@
 package org.apache.airavata.server.auth;
 
 import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.Map;
 import org.springframework.security.core.GrantedAuthority;
 import org.springframework.security.core.authority.SimpleGrantedAuthority;
 import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal;
@@ -27,13 +29,16 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal;
 import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;
 
 /**
- * Wraps CILogon's token introspection response and populates authorities resolved
- * via {@link UserRoleLookupService} (looked up by username, not trusted from the
- * token), so that {@code @PreAuthorize(...)} works on service methods.
+ * Wraps CILogon's token introspection response, enriches it with profile claims
+ * (email, name, etc.) from CILogon's userinfo endpoint, and populates authorities
+ * resolved via {@link UserRoleLookupService} (looked up by username, not trusted
+ * from the token), so that {@code @PreAuthorize(...)} works on service methods.
  *
  * <p>CILogon access tokens for standard (non "Full Service") OAuth clients are
  * opaque, not self-contained JWTs, so tokens are validated by calling CILogon's
  * introspection endpoint rather than by decoding a JWT locally against a JWK set.
+ * Introspection alone only returns a small, fixed claim set — profile attributes
+ * come from a second call to userinfo, using the same bearer token.
  *
  * <p>Not a Spring bean itself (it implements the same interface as the delegate
  * it wraps, which would make component-scanning it ambiguous/circular) —
@@ -43,20 +48,28 @@ public class UserRoleOpaqueTokenIntrospector implements OpaqueTokenIntrospector
 
     private final OpaqueTokenIntrospector delegate;
     private final UserRoleLookupService userRoleLookupService;
+    private final CILogonUserInfoClient userInfoClient;
 
     public UserRoleOpaqueTokenIntrospector(
-            OpaqueTokenIntrospector delegate, UserRoleLookupService userRoleLookupService) {
+            OpaqueTokenIntrospector delegate,
+            UserRoleLookupService userRoleLookupService,
+            CILogonUserInfoClient userInfoClient) {
         this.delegate = delegate;
         this.userRoleLookupService = userRoleLookupService;
+        this.userInfoClient = userInfoClient;
     }
 
     @Override
     public OAuth2AuthenticatedPrincipal introspect(String token) {
         OAuth2AuthenticatedPrincipal principal = delegate.introspect(token);
 
-        String username = principal.getAttribute("username");
+        Map<String, Object> attributes = new LinkedHashMap<>(principal.getAttributes());
+        // Introspection claims (active, scope, exp, sub, ...) win on conflict; userinfo only fills in gaps.
+        userInfoClient.fetchUserInfo(token).forEach(attributes::putIfAbsent);
+
+        String username = (String) attributes.get("username");
         if (username == null) {
-            username = principal.getAttribute("preferred_username");
+            username = (String) attributes.get("preferred_username");
         }
         if (username == null) {
             username = principal.getName();
@@ -67,6 +80,6 @@ public class UserRoleOpaqueTokenIntrospector implements OpaqueTokenIntrospector
                 .map(GrantedAuthority.class::cast)
                 .toList();
 
-        return new DefaultOAuth2AuthenticatedPrincipal(principal.getName(), principal.getAttributes(), authorities);
+        return new DefaultOAuth2AuthenticatedPrincipal(principal.getName(), attributes, authorities);
     }
 }
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 39240ba2cd..fe67dfab6d 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -70,6 +70,7 @@ airavata.cors.allowed-origins=*
 # id/secret here authenticate introspection calls to CILogon and must belong
 # to a client CILogon will accept for RFC 7662 introspection.
 airavata.security.cilogon.introspection-uri=https://cilogon.org/oauth2/introspect
+airavata.security.cilogon.userinfo-uri=https://cilogon.org/oauth2/userinfo
 airavata.security.cilogon.client-id=${CILOGON_CLIENT_ID:}
 airavata.security.cilogon.client-secret=${CILOGON_CLIENT_SECRET:}
 iam.server.url=http://localhost:18080