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