From 34a73aabbb18f3e6f0b11fdc28175080265ccc2c Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Fri, 31 Jul 2026 16:14:06 +0200 Subject: [PATCH 1/4] Add revoke endpoint request for ORCID --- etc/cas/config/cas.properties | 6 + ...kenCaptureAuthenticationPostProcessor.java | 108 +++++++++++++++ .../support/OrcidTokenRevocationClient.java | 73 +++++++++++ ...cationEventExecutionPlanConfiguration.java | 63 +++++++++ .../config/OrcidTokenJpaConfiguration.java | 97 ++++++++++++++ .../model/OsfOrcidRevocationProperties.java | 36 +++++ .../cos/cas/osf/dao/JpaOsfOrcidTokenDao.java | 72 ++++++++++ .../io/cos/cas/osf/dao/OsfOrcidTokenDao.java | 41 ++++++ .../cos/cas/osf/orcidtoken/OsfOrcidToken.java | 86 ++++++++++++ .../OsfOrcidTokenCryptoConverter.java | 29 ++++ .../util/crypto/OrcidTokenCipherExecutor.java | 124 ++++++++++++++++++ .../OrcidTokenRevocationWebConfiguration.java | 45 +++++++ .../rest/OrcidTokenRevocationController.java | 105 +++++++++++++++ .../web/rest/OrcidTokenRevocationRequest.java | 22 ++++ .../AuthenticationProperties.java | 7 + src/main/resources/META-INF/spring.factories | 3 + 16 files changed, 917 insertions(+) create mode 100644 src/main/java/io/cos/cas/osf/authentication/postprocessor/OrcidTokenCaptureAuthenticationPostProcessor.java create mode 100644 src/main/java/io/cos/cas/osf/authentication/support/OrcidTokenRevocationClient.java create mode 100644 src/main/java/io/cos/cas/osf/config/OrcidTokenCaptureAuthenticationEventExecutionPlanConfiguration.java create mode 100644 src/main/java/io/cos/cas/osf/config/OrcidTokenJpaConfiguration.java create mode 100644 src/main/java/io/cos/cas/osf/configuration/model/OsfOrcidRevocationProperties.java create mode 100644 src/main/java/io/cos/cas/osf/dao/JpaOsfOrcidTokenDao.java create mode 100644 src/main/java/io/cos/cas/osf/dao/OsfOrcidTokenDao.java create mode 100644 src/main/java/io/cos/cas/osf/orcidtoken/OsfOrcidToken.java create mode 100644 src/main/java/io/cos/cas/osf/orcidtoken/OsfOrcidTokenCryptoConverter.java create mode 100644 src/main/java/io/cos/cas/osf/util/crypto/OrcidTokenCipherExecutor.java create mode 100644 src/main/java/io/cos/cas/osf/web/config/OrcidTokenRevocationWebConfiguration.java create mode 100644 src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationController.java create mode 100644 src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationRequest.java diff --git a/etc/cas/config/cas.properties b/etc/cas/config/cas.properties index dba0355a..0d9fb5c5 100644 --- a/etc/cas/config/cas.properties +++ b/etc/cas/config/cas.properties @@ -267,6 +267,12 @@ cas.authn.pac4j.orcid.client-name=orcid cas.authn.pac4j.orcid.enabled=true cas.authn.pac4j.orcid.callback-url-type=QUERY_PARAMETER # +# ORCID Token Revocation: allows OSF to ask CAS to revoke a stored ORCID OAuth token (GDPR delete) +# +cas.authn.osf-orcid-revocation.revoke-url=${OAUTH_ORCID_REVOKE_URL:https://orcid.org/oauth/revoke} +cas.authn.osf-orcid-revocation.shared-secret=${OSF_ORCID_REVOKE_SHARED_SECRET:} +cas.authn.osf-orcid-revocation.token-encryption-key=${OSF_ORCID_TOKEN_ENCRYPTION_KEY:} +# # Delegation Client: CAS # cas.authn.pac4j.cas[0].login-url=${CAS_CORD_LOGIN_URL:https://bprdeis.cord.edu:8443/cas/login} diff --git a/src/main/java/io/cos/cas/osf/authentication/postprocessor/OrcidTokenCaptureAuthenticationPostProcessor.java b/src/main/java/io/cos/cas/osf/authentication/postprocessor/OrcidTokenCaptureAuthenticationPostProcessor.java new file mode 100644 index 00000000..5cbcee69 --- /dev/null +++ b/src/main/java/io/cos/cas/osf/authentication/postprocessor/OrcidTokenCaptureAuthenticationPostProcessor.java @@ -0,0 +1,108 @@ +package io.cos.cas.osf.authentication.postprocessor; + +import io.cos.cas.osf.authentication.support.OrcidTokenRevocationClient; +import io.cos.cas.osf.dao.OsfOrcidTokenDao; +import io.cos.cas.osf.orcidtoken.OsfOrcidToken; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.apache.commons.lang3.StringUtils; +import org.apereo.cas.authentication.AuthenticationBuilder; +import org.apereo.cas.authentication.AuthenticationException; +import org.apereo.cas.authentication.AuthenticationPostProcessor; +import org.apereo.cas.authentication.AuthenticationTransaction; +import org.apereo.cas.authentication.Credential; +import org.apereo.cas.authentication.principal.ClientCredential; + +import org.pac4j.core.profile.CommonProfile; +import org.pac4j.oauth.profile.orcid.OrcidProfile; + +/** + * This is {@link OrcidTokenCaptureAuthenticationPostProcessor}. + * + * Captures the ORCID OAuth access token on a successful ORCID login (via pac4j delegated authentication) and stores + * it in the writable {@code osf_orcid_oauth_token} table, so that OSF can later ask CAS to revoke it (see + * {@code OrcidTokenRevocationController}, used on GDPR delete). + * + *

Unverified assumption, pending a live spike: this relies on + * {@link ClientCredential#getUserProfile()} already being populated (by CAS's pac4j-based authentication handling) + * by the time {@link AuthenticationPostProcessor}s run for the transaction. This has been confirmed against the + * compiled {@code ClientCredential} / {@code OAuth20Profile} API shapes (plain, nullable {@code getUserProfile()} / + * {@code getAccessToken(): String} getters), but the exact point in the CAS 6.2.8 + pac4j 4.1.0 authentication + * pipeline where {@code setUserProfile(...)} is actually invoked could not be confirmed via static inspection alone + * (the relevant handler class is bundled only in the full CAS webapp WAR, not the thin support/api jars used to + * develop this feature). If {@link #captureOrcidToken(ClientCredential)} logs the DEBUG "no resolved profile yet" + * message on every real ORCID login, this hook needs to move to a different point in the pipeline (the fallback + * discussed in the design doc is capturing inside {@code OsfPrincipalFromNonInteractiveCredentialsAction} instead, + * though as currently written that class also runs before profile resolution and would need further changes).

+ * + *

Wrapped entirely in try/catch: a failure here must never break a login.

+ * + * @author Longze Chen + * @since 26.1.0 + */ +@Slf4j +@RequiredArgsConstructor +public class OrcidTokenCaptureAuthenticationPostProcessor implements AuthenticationPostProcessor { + + private final String orcidClientName; + + private final String orcidClientId; + + private final String orcidClientSecret; + + private final String orcidRevokeUrl; + + private final OsfOrcidTokenDao osfOrcidTokenDao; + + @Override + public boolean supports(final Credential credential) { + return credential instanceof ClientCredential + && orcidClientName.equalsIgnoreCase(((ClientCredential) credential).getClientName()); + } + + @Override + public void process( + final AuthenticationBuilder builder, + final AuthenticationTransaction transaction + ) throws AuthenticationException { + transaction.getCredentials().stream() + .filter(this::supports) + .map(credential -> (ClientCredential) credential) + .forEach(this::captureOrcidToken); + } + + private void captureOrcidToken(final ClientCredential credential) { + try { + final CommonProfile profile = credential.getUserProfile(); + if (!(profile instanceof OrcidProfile)) { + LOGGER.debug( + "No resolved ORCID profile on the client credential yet (profile=[{}]); " + + "skipping ORCID token capture for this authentication event.", + profile + ); + return; + } + final OrcidProfile orcidProfile = (OrcidProfile) profile; + final String orcidId = orcidProfile.getOrcid(); + final String accessToken = orcidProfile.getAccessToken(); + if (StringUtils.isBlank(orcidId) || StringUtils.isBlank(accessToken)) { + LOGGER.warn("ORCID login resolved without an ORCID iD or access token; nothing to capture."); + return; + } + final OsfOrcidToken existing = osfOrcidTokenDao.findByOrcidId(orcidId); + if (existing != null + && StringUtils.isNotBlank(existing.getAccessToken()) + && !existing.getAccessToken().equals(accessToken)) { + LOGGER.debug("Reconnect detected for ORCID iD [{}]; revoking the previous token before replacing it.", orcidId); + OrcidTokenRevocationClient.revoke(orcidRevokeUrl, orcidClientId, orcidClientSecret, existing.getAccessToken()); + } + osfOrcidTokenDao.upsertToken(orcidId, accessToken, null, null); + LOGGER.info("Captured ORCID OAuth token for ORCID iD [{}]", orcidId); + } catch (final Exception e) { + LOGGER.warn("Failed to capture ORCID OAuth token; login proceeds unaffected. Error: {}", e.getMessage()); + LOGGER.debug("Full stack trace of the ORCID token capture failure:", e); + } + } +} diff --git a/src/main/java/io/cos/cas/osf/authentication/support/OrcidTokenRevocationClient.java b/src/main/java/io/cos/cas/osf/authentication/support/OrcidTokenRevocationClient.java new file mode 100644 index 00000000..6e681053 --- /dev/null +++ b/src/main/java/io/cos/cas/osf/authentication/support/OrcidTokenRevocationClient.java @@ -0,0 +1,73 @@ +package io.cos.cas.osf.authentication.support; + +import lombok.extern.slf4j.Slf4j; + +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.client.fluent.Form; +import org.apache.http.client.fluent.Request; + +import java.io.IOException; + +/** + * This is {@link OrcidTokenRevocationClient}. + * + * Calls ORCID's own OAuth revocation endpoint ({@code POST https://orcid.org/oauth/revoke}). Used both by + * {@code OrcidTokenCaptureAuthenticationPostProcessor} (best-effort revoke-then-replace on reconnect) and by + * {@code OrcidTokenRevocationController} (revocation triggered by OSF, e.g. on GDPR delete). + * + * Per ORCID's API docs, revoking either the access token or the refresh token revokes the pair, and success is + * {@code HTTP 200 OK} with an empty body. + * + * @author Longze Chen + * @since 26.1.0 + */ +@Slf4j +public final class OrcidTokenRevocationClient { + + private static final int CONNECT_AND_SOCKET_TIMEOUT_IN_MILLISECONDS = 5000; + + private OrcidTokenRevocationClient() { + } + + /** + * Best-effort revoke a token against ORCID. Never throws; logs and returns {@code false} on any failure. + * + * @param revokeUrl ORCID's OAuth revocation endpoint + * @param clientId CAS's ORCID OAuth client id + * @param clientSecret CAS's ORCID OAuth client secret + * @param token the access or refresh token to revoke + * @return {@code true} if ORCID responded with {@code HTTP 200}, {@code false} otherwise + */ + public static boolean revoke( + final String revokeUrl, + final String clientId, + final String clientSecret, + final String token + ) { + try { + final HttpResponse response = Request.Post(revokeUrl) + .connectTimeout(CONNECT_AND_SOCKET_TIMEOUT_IN_MILLISECONDS) + .socketTimeout(CONNECT_AND_SOCKET_TIMEOUT_IN_MILLISECONDS) + .bodyForm( + Form.form() + .add("client_id", clientId) + .add("client_secret", clientSecret) + .add("token", token) + .build() + ) + .execute() + .returnResponse(); + final int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode == HttpStatus.SC_OK) { + LOGGER.debug("Successfully revoked ORCID token against [{}]", revokeUrl); + return true; + } + LOGGER.warn("ORCID token revocation against [{}] returned unexpected status [{}]", revokeUrl, statusCode); + return false; + } catch (final IOException e) { + LOGGER.warn("Failed to revoke ORCID token against [{}]: {}", revokeUrl, e.getMessage()); + return false; + } + } +} diff --git a/src/main/java/io/cos/cas/osf/config/OrcidTokenCaptureAuthenticationEventExecutionPlanConfiguration.java b/src/main/java/io/cos/cas/osf/config/OrcidTokenCaptureAuthenticationEventExecutionPlanConfiguration.java new file mode 100644 index 00000000..22812572 --- /dev/null +++ b/src/main/java/io/cos/cas/osf/config/OrcidTokenCaptureAuthenticationEventExecutionPlanConfiguration.java @@ -0,0 +1,63 @@ +package io.cos.cas.osf.config; + +import io.cos.cas.osf.authentication.postprocessor.OrcidTokenCaptureAuthenticationPostProcessor; +import io.cos.cas.osf.dao.OsfOrcidTokenDao; + +import lombok.extern.slf4j.Slf4j; + +import org.apereo.cas.authentication.AuthenticationEventExecutionPlanConfigurer; +import org.apereo.cas.authentication.AuthenticationPostProcessor; +import org.apereo.cas.configuration.CasConfigurationProperties; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * This is {@link OrcidTokenCaptureAuthenticationEventExecutionPlanConfiguration}. + * + * Registers {@link OrcidTokenCaptureAuthenticationPostProcessor} into the authentication event execution plan, the + * same way {@code OsfPostgresAuthenticationEventExecutionPlanConfiguration} registers its handler. + * + * @author Longze Chen + * @since 26.1.0 + */ +@Configuration("orcidTokenCaptureAuthenticationEventExecutionPlanConfiguration") +@EnableConfigurationProperties(CasConfigurationProperties.class) +@Slf4j +public class OrcidTokenCaptureAuthenticationEventExecutionPlanConfiguration { + + @Autowired + private CasConfigurationProperties casProperties; + + @Autowired + private ObjectProvider osfOrcidTokenDao; + + @ConditionalOnMissingBean(name = "orcidTokenCaptureAuthenticationPostProcessor") + @Bean + public AuthenticationPostProcessor orcidTokenCaptureAuthenticationPostProcessor() { + return new OrcidTokenCaptureAuthenticationPostProcessor( + casProperties.getAuthn().getPac4j().getOrcid().getClientName(), + casProperties.getAuthn().getPac4j().getOrcid().getId(), + casProperties.getAuthn().getPac4j().getOrcid().getSecret(), + casProperties.getAuthn().getOsfOrcidRevocation().getRevokeUrl(), + osfOrcidTokenDao.getObject() + ); + } + + @ConditionalOnMissingBean(name = "orcidTokenCaptureAuthenticationEventExecutionPlanConfigurer") + @Bean + public AuthenticationEventExecutionPlanConfigurer orcidTokenCaptureAuthenticationEventExecutionPlanConfigurer() { + return plan -> { + LOGGER.debug( + "Register [{}] to the authentication event execution plan", + OrcidTokenCaptureAuthenticationPostProcessor.class.getSimpleName() + ); + plan.registerAuthenticationPostProcessor(orcidTokenCaptureAuthenticationPostProcessor()); + }; + } +} diff --git a/src/main/java/io/cos/cas/osf/config/OrcidTokenJpaConfiguration.java b/src/main/java/io/cos/cas/osf/config/OrcidTokenJpaConfiguration.java new file mode 100644 index 00000000..a94bd020 --- /dev/null +++ b/src/main/java/io/cos/cas/osf/config/OrcidTokenJpaConfiguration.java @@ -0,0 +1,97 @@ +package io.cos.cas.osf.config; + +import io.cos.cas.osf.dao.JpaOsfOrcidTokenDao; +import io.cos.cas.osf.dao.OsfOrcidTokenDao; +import io.cos.cas.osf.util.crypto.OrcidTokenCipherExecutor; + +import org.apereo.cas.configuration.CasConfigurationProperties; +import org.apereo.cas.configuration.model.support.jpa.JpaConfigurationContext; +import org.apereo.cas.configuration.support.JpaBeans; +import org.apereo.cas.jpa.JpaBeanFactory; +import org.apereo.cas.util.spring.ApplicationContextProvider; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.annotation.PostConstruct; +import javax.persistence.EntityManagerFactory; +import javax.sql.DataSource; +import java.util.List; + +/** + * This is {@link OrcidTokenJpaConfiguration}. + * + * Configures a second, writable JPA persistence unit dedicated to the {@code osf_orcid_oauth_token} table. This is + * intentionally separate from {@link JpaOsfDaoConfiguration}, which is read-only at the driver level + * ({@code cas.authn.osf-postgres.jpa.url} is opened with {@code readOnly=true&readOnlyMode=always}) and cannot be + * used to persist new state. Instead, this context reuses the connection settings of CAS's own writable ticket + * registry database ({@code cas.ticket.registry.jpa.*}, {@code ddl-auto=update}), so Hibernate creates the new table + * automatically on startup with no separate migration required. + * + * @author Longze Chen + * @since 26.1.0 + */ +@Configuration("orcidTokenJpaConfiguration") +@EnableConfigurationProperties(CasConfigurationProperties.class) +public class OrcidTokenJpaConfiguration { + + private static final List ORCID_TOKEN_MODEL_PACKAGES_TO_SCAN = List.of("io.cos.cas.osf.orcidtoken"); + + @Autowired + @Qualifier("jpaBeanFactory") + private ObjectProvider jpaBeanFactory; + + @Autowired + private CasConfigurationProperties casProperties; + + @Autowired + private ApplicationContext applicationContext; + + @PostConstruct + public void initializeOrcidTokenCipher() { + OrcidTokenCipherExecutor.initialize(casProperties.getAuthn().getOsfOrcidRevocation().getTokenEncryptionKey()); + } + + @Lazy + @Bean + public LocalContainerEntityManagerFactoryBean orcidTokenEntityManagerFactory() { + ApplicationContextProvider.holdApplicationContext(applicationContext); + final JpaBeanFactory factory = jpaBeanFactory.getObject(); + final JpaConfigurationContext ctx = new JpaConfigurationContext( + factory.newJpaVendorAdapter(casProperties.getJdbc()), + "orcidTokenContext", + ORCID_TOKEN_MODEL_PACKAGES_TO_SCAN, + orcidTokenDataSource()); + return factory.newEntityManagerFactoryBean(ctx, casProperties.getTicket().getRegistry().getJpa()); + } + + @Bean + public PlatformTransactionManager orcidTokenTransactionManager( + @Qualifier("orcidTokenEntityManagerFactory") final EntityManagerFactory emf + ) { + final JpaTransactionManager mgmr = new JpaTransactionManager(); + mgmr.setEntityManagerFactory(emf); + return mgmr; + } + + @Bean + public DataSource orcidTokenDataSource() { + return JpaBeans.newDataSource(casProperties.getTicket().getRegistry().getJpa()); + } + + @ConditionalOnMissingBean(name = "osfOrcidTokenDao") + @Bean + public OsfOrcidTokenDao osfOrcidTokenDao() { + return new JpaOsfOrcidTokenDao(); + } +} diff --git a/src/main/java/io/cos/cas/osf/configuration/model/OsfOrcidRevocationProperties.java b/src/main/java/io/cos/cas/osf/configuration/model/OsfOrcidRevocationProperties.java new file mode 100644 index 00000000..1ec09d82 --- /dev/null +++ b/src/main/java/io/cos/cas/osf/configuration/model/OsfOrcidRevocationProperties.java @@ -0,0 +1,36 @@ +package io.cos.cas.osf.configuration.model; + +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; + +import java.io.Serializable; + +/** + * This is {@link OsfOrcidRevocationProperties}. + * + * @author Longze Chen + * @since 26.1.0 + */ +@Getter +@Setter +@Accessors(chain = true) +public class OsfOrcidRevocationProperties implements Serializable { + + private static final long serialVersionUID = -2836917320958203451L; + + /** + * ORCID's OAuth token revocation endpoint. + */ + private String revokeUrl = "https://orcid.org/oauth/revoke"; + + /** + * The shared secret used to authenticate OSF's calls to {@code POST /osf/orcid/revoke}. + */ + private String sharedSecret; + + /** + * The symmetric key used to encrypt / decrypt stored ORCID access and refresh tokens at rest. + */ + private String tokenEncryptionKey; +} diff --git a/src/main/java/io/cos/cas/osf/dao/JpaOsfOrcidTokenDao.java b/src/main/java/io/cos/cas/osf/dao/JpaOsfOrcidTokenDao.java new file mode 100644 index 00000000..79ae5d07 --- /dev/null +++ b/src/main/java/io/cos/cas/osf/dao/JpaOsfOrcidTokenDao.java @@ -0,0 +1,72 @@ +package io.cos.cas.osf.dao; + +import io.cos.cas.osf.orcidtoken.OsfOrcidToken; + +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.transaction.annotation.Transactional; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import javax.persistence.PersistenceException; +import javax.persistence.TypedQuery; +import javax.validation.constraints.NotNull; + +/** + * This is {@link JpaOsfOrcidTokenDao}. + * + * @author Longze Chen + * @since 26.1.0 + */ +@Slf4j +@NoArgsConstructor +@Transactional(transactionManager = "orcidTokenTransactionManager") +public class JpaOsfOrcidTokenDao implements OsfOrcidTokenDao { + + @NotNull + @PersistenceContext(unitName = "orcidTokenEntityManagerFactory") + private EntityManager entityManager; + + @Override + public OsfOrcidToken findByOrcidId(final String orcidId) { + try { + final TypedQuery query = entityManager.createQuery( + "select t from OsfOrcidToken t where t.orcidId = :orcidId", + OsfOrcidToken.class + ); + query.setParameter("orcidId", orcidId); + return query.getSingleResult(); + } catch (final PersistenceException e) { + return null; + } + } + + @Override + public OsfOrcidToken upsertToken( + final String orcidId, + final String accessToken, + final String refreshToken, + final String scope + ) { + OsfOrcidToken token = findByOrcidId(orcidId); + if (token == null) { + token = new OsfOrcidToken(); + token.setOrcidId(orcidId); + } + token.setAccessToken(accessToken); + token.setRefreshToken(refreshToken); + token.setScope(scope); + return entityManager.merge(token); + } + + @Override + public void deleteByOrcidId(final String orcidId) { + final OsfOrcidToken token = findByOrcidId(orcidId); + if (token != null) { + entityManager.remove(entityManager.contains(token) ? token : entityManager.merge(token)); + } else { + LOGGER.debug("No stored ORCID token found for orcid id [{}]; nothing to delete", orcidId); + } + } +} diff --git a/src/main/java/io/cos/cas/osf/dao/OsfOrcidTokenDao.java b/src/main/java/io/cos/cas/osf/dao/OsfOrcidTokenDao.java new file mode 100644 index 00000000..57e80b75 --- /dev/null +++ b/src/main/java/io/cos/cas/osf/dao/OsfOrcidTokenDao.java @@ -0,0 +1,41 @@ +package io.cos.cas.osf.dao; + +import io.cos.cas.osf.orcidtoken.OsfOrcidToken; + +/** + * This is {@link OsfOrcidTokenDao}. + * + * DAO for the writable {@code osf_orcid_oauth_token} table, used to capture ORCID OAuth tokens on login and to + * support CAS-side revocation triggered by OSF (e.g. GDPR delete). + * + * @author Longze Chen + * @since 26.1.0 + */ +public interface OsfOrcidTokenDao { + + /** + * Find the stored token for a given ORCID iD, if any. + * + * @param orcidId the ORCID iD + * @return the stored token, or {@code null} if none exists + */ + OsfOrcidToken findByOrcidId(String orcidId); + + /** + * Insert or update the stored token for a given ORCID iD. + * + * @param orcidId the ORCID iD (natural key) + * @param accessToken the ORCID OAuth access token + * @param refreshToken the ORCID OAuth refresh token, may be {@code null} + * @param scope the granted OAuth scope, may be {@code null} + * @return the persisted token entity + */ + OsfOrcidToken upsertToken(String orcidId, String accessToken, String refreshToken, String scope); + + /** + * Delete the stored token for a given ORCID iD, if any. A no-op if none exists. + * + * @param orcidId the ORCID iD + */ + void deleteByOrcidId(String orcidId); +} diff --git a/src/main/java/io/cos/cas/osf/orcidtoken/OsfOrcidToken.java b/src/main/java/io/cos/cas/osf/orcidtoken/OsfOrcidToken.java new file mode 100644 index 00000000..d0ca6bd1 --- /dev/null +++ b/src/main/java/io/cos/cas/osf/orcidtoken/OsfOrcidToken.java @@ -0,0 +1,86 @@ +package io.cos.cas.osf.orcidtoken; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import javax.persistence.Column; +import javax.persistence.Convert; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.PrePersist; +import javax.persistence.PreUpdate; +import javax.persistence.Table; +import java.io.Serializable; +import java.util.Date; + +/** + * This is {@link OsfOrcidToken}. + * + * Stores the ORCID OAuth access / refresh token captured on ORCID login, keyed by ORCID iD, so that OSF can later ask + * CAS to revoke it (e.g. on GDPR delete). Deliberately its own class hierarchy rather than a subclass of + * {@link io.cos.cas.osf.model.AbstractOsfModel}: that base class is tied to the read-only OSF Postgres persistence + * unit ({@code JpaOsfDaoConfiguration}), whereas this entity lives in the separate, writable persistence unit + * defined by {@code OrcidTokenJpaConfiguration}. + * + *

Lives in {@code io.cos.cas.osf.orcidtoken} rather than under {@code io.cos.cas.osf.model} on purpose: + * {@code JpaOsfDaoConfiguration.jpaOsfDaoModelPackagesToScan()} returns the (undeduplicated) package name of every + * {@code AbstractOsfModel} subtype it finds, and Spring/Hibernate's package scanning recurses into subpackages — so + * nesting this under {@code io.cos.cas.osf.model} caused {@link OsfOrcidTokenCryptoConverter} to be swept into the + * read-only persistence unit's scan too (registered once per duplicate package-name entry), which Hibernate rejects + * with {@code AttributeConverter class ... registered multiple times}. Keeping this package outside that subtree + * avoids the collision entirely.

+ * + * @author Longze Chen + * @since 26.1.0 + */ +@Entity +@Table(name = "osf_orcid_oauth_token") +@NoArgsConstructor +@Getter +@Setter +@ToString(exclude = {"accessToken", "refreshToken"}) +public class OsfOrcidToken implements Serializable { + + private static final long serialVersionUID = 2778546873719340158L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "orcid_id", nullable = false, unique = true) + private String orcidId; + + @Convert(converter = OsfOrcidTokenCryptoConverter.class) + @Column(name = "access_token", nullable = false, length = 4096) + private String accessToken; + + @Convert(converter = OsfOrcidTokenCryptoConverter.class) + @Column(name = "refresh_token", length = 4096) + private String refreshToken; + + @Column(name = "scope") + private String scope; + + @Column(name = "date_created", nullable = false) + private Date dateCreated; + + @Column(name = "date_modified", nullable = false) + private Date dateModified; + + @PrePersist + protected void onCreate() { + final Date now = new Date(); + this.dateCreated = now; + this.dateModified = now; + } + + @PreUpdate + protected void onUpdate() { + this.dateModified = new Date(); + } +} diff --git a/src/main/java/io/cos/cas/osf/orcidtoken/OsfOrcidTokenCryptoConverter.java b/src/main/java/io/cos/cas/osf/orcidtoken/OsfOrcidTokenCryptoConverter.java new file mode 100644 index 00000000..e5f8e1a6 --- /dev/null +++ b/src/main/java/io/cos/cas/osf/orcidtoken/OsfOrcidTokenCryptoConverter.java @@ -0,0 +1,29 @@ +package io.cos.cas.osf.orcidtoken; + +import io.cos.cas.osf.util.crypto.OrcidTokenCipherExecutor; + +import javax.persistence.AttributeConverter; +import javax.persistence.Converter; + +/** + * This is {@link OsfOrcidTokenCryptoConverter}. + * + * Transparently encrypts / decrypts {@link OsfOrcidToken#accessToken} and {@link OsfOrcidToken#refreshToken} so that + * ORCID's long-lived OAuth tokens are never persisted to {@code osf_orcid_oauth_token} in plaintext. + * + * @author Longze Chen + * @since 26.1.0 + */ +@Converter +public class OsfOrcidTokenCryptoConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(final String attribute) { + return OrcidTokenCipherExecutor.encrypt(attribute); + } + + @Override + public String convertToEntityAttribute(final String dbData) { + return OrcidTokenCipherExecutor.decrypt(dbData); + } +} diff --git a/src/main/java/io/cos/cas/osf/util/crypto/OrcidTokenCipherExecutor.java b/src/main/java/io/cos/cas/osf/util/crypto/OrcidTokenCipherExecutor.java new file mode 100644 index 00000000..97ec559f --- /dev/null +++ b/src/main/java/io/cos/cas/osf/util/crypto/OrcidTokenCipherExecutor.java @@ -0,0 +1,124 @@ +package io.cos.cas.osf.util.crypto; + +import lombok.extern.slf4j.Slf4j; + +import org.apache.commons.lang3.StringUtils; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * This is {@link OrcidTokenCipherExecutor}. + * + * Encrypts / decrypts the ORCID access and refresh tokens stored in {@code osf_orcid_oauth_token} at rest, using + * AES/GCM with a key derived from {@code cas.authn.osf-orcid-revocation.token-encryption-key}. JPA + * {@link javax.persistence.AttributeConverter} instances are instantiated directly by the persistence provider + * (not by Spring), so the raw configured secret is stashed here as a static field once, during + * {@code OrcidTokenJpaConfiguration} bean initialization, and read from here by + * {@link io.cos.cas.osf.orcidtoken.OsfOrcidTokenCryptoConverter}. + * + * @author Longze Chen + * @since 26.1.0 + */ +@Slf4j +public final class OrcidTokenCipherExecutor { + + private static final String CIPHER_TRANSFORMATION = "AES/GCM/NoPadding"; + + private static final String KEY_ALGORITHM = "AES"; + + private static final int GCM_IV_LENGTH_IN_BYTES = 12; + + private static final int GCM_TAG_LENGTH_IN_BITS = 128; + + private static volatile SecretKeySpec secretKeySpec; + + private OrcidTokenCipherExecutor() { + } + + /** + * Initialize the static encryption key from the configured shared secret. Safe to call more than once (e.g. on + * context refresh); the last value wins. + * + * @param rawKey the configured {@code cas.authn.osf-orcid-revocation.token-encryption-key} + */ + public static void initialize(final String rawKey) { + if (StringUtils.isBlank(rawKey)) { + LOGGER.warn("ORCID token encryption key is not configured; ORCID token storage will fail until it is set."); + secretKeySpec = null; + return; + } + try { + final MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + final byte[] normalizedKey = sha256.digest(rawKey.getBytes(StandardCharsets.UTF_8)); + secretKeySpec = new SecretKeySpec(normalizedKey, KEY_ALGORITHM); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException("Unable to initialize ORCID token cipher: " + e.getMessage(), e); + } + } + + /** + * Encrypt a plaintext value, returning a Base64 string of {@code iv || ciphertext || tag}. + * + * @param plainText the value to encrypt + * @return the encrypted value, or {@code null} if the input is {@code null} + */ + public static String encrypt(final String plainText) { + if (plainText == null) { + return null; + } + try { + final byte[] iv = new byte[GCM_IV_LENGTH_IN_BYTES]; + new SecureRandom().nextBytes(iv); + final Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, requireKey(), new GCMParameterSpec(GCM_TAG_LENGTH_IN_BITS, iv)); + final byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + final byte[] combined = new byte[iv.length + cipherText.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(cipherText, 0, combined, iv.length, cipherText.length); + return Base64.getEncoder().encodeToString(combined); + } catch (final Exception e) { + throw new IllegalStateException("Failed to encrypt ORCID token: " + e.getMessage(), e); + } + } + + /** + * Decrypt a value previously produced by {@link #encrypt(String)}. + * + * @param encoded the Base64-encoded {@code iv || ciphertext || tag} + * @return the decrypted plaintext, or {@code null} if the input is {@code null} + */ + public static String decrypt(final String encoded) { + if (encoded == null) { + return null; + } + try { + final byte[] combined = Base64.getDecoder().decode(encoded); + final byte[] iv = new byte[GCM_IV_LENGTH_IN_BYTES]; + System.arraycopy(combined, 0, iv, 0, iv.length); + final byte[] cipherText = new byte[combined.length - iv.length]; + System.arraycopy(combined, iv.length, cipherText, 0, cipherText.length); + final Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, requireKey(), new GCMParameterSpec(GCM_TAG_LENGTH_IN_BITS, iv)); + return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8); + } catch (final Exception e) { + throw new IllegalStateException("Failed to decrypt ORCID token: " + e.getMessage(), e); + } + } + + private static SecretKeySpec requireKey() { + final SecretKeySpec key = secretKeySpec; + if (key == null) { + throw new IllegalStateException( + "ORCID token cipher is not initialized; check cas.authn.osf-orcid-revocation.token-encryption-key" + ); + } + return key; + } +} diff --git a/src/main/java/io/cos/cas/osf/web/config/OrcidTokenRevocationWebConfiguration.java b/src/main/java/io/cos/cas/osf/web/config/OrcidTokenRevocationWebConfiguration.java new file mode 100644 index 00000000..6d07cba6 --- /dev/null +++ b/src/main/java/io/cos/cas/osf/web/config/OrcidTokenRevocationWebConfiguration.java @@ -0,0 +1,45 @@ +package io.cos.cas.osf.web.config; + +import io.cos.cas.osf.dao.OsfOrcidTokenDao; +import io.cos.cas.osf.web.rest.OrcidTokenRevocationController; + +import org.apereo.cas.configuration.CasConfigurationProperties; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * This is {@link OrcidTokenRevocationWebConfiguration}. + * + * Registers {@link OrcidTokenRevocationController} as a Spring MVC controller bean; CAS discovers {@code @Controller} + * beans regardless of how they were registered (auto-config bean method here, not classpath component-scan). + * + * @author Longze Chen + * @since 26.1.0 + */ +@Configuration("orcidTokenRevocationWebConfiguration") +@EnableConfigurationProperties(CasConfigurationProperties.class) +public class OrcidTokenRevocationWebConfiguration { + + @Autowired + private CasConfigurationProperties casProperties; + + @Autowired + private ObjectProvider osfOrcidTokenDao; + + @ConditionalOnMissingBean(name = "orcidTokenRevocationController") + @Bean + public OrcidTokenRevocationController orcidTokenRevocationController() { + return new OrcidTokenRevocationController( + osfOrcidTokenDao.getObject(), + casProperties.getAuthn().getOsfOrcidRevocation().getSharedSecret(), + casProperties.getAuthn().getOsfOrcidRevocation().getRevokeUrl(), + casProperties.getAuthn().getPac4j().getOrcid().getId(), + casProperties.getAuthn().getPac4j().getOrcid().getSecret() + ); + } +} diff --git a/src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationController.java b/src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationController.java new file mode 100644 index 00000000..918bccf8 --- /dev/null +++ b/src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationController.java @@ -0,0 +1,105 @@ +package io.cos.cas.osf.web.rest; + +import io.cos.cas.osf.authentication.support.OrcidTokenRevocationClient; +import io.cos.cas.osf.dao.OsfOrcidTokenDao; +import io.cos.cas.osf.orcidtoken.OsfOrcidToken; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.ResponseBody; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** + * This is {@link OrcidTokenRevocationController}. + * + * Handles {@code POST /osf/orcid/revoke}, the endpoint OSF calls (from {@code OSFUser._clear_identifying_information()} + * on GDPR delete) to ask CAS to revoke a stored ORCID OAuth token. Authenticated via a shared-secret bearer header + * (a fresh internal-service auth boundary, since an ORCID iD is not itself a secret, unlike client id / client secret + * pairs used elsewhere). + * + * Behavior, regardless of whether a stored token is found: the local row is always removed and {@code HTTP 204} is + * returned for any successfully processed request (matching "removes regardless of outcome"); {@code 401} is + * returned only for a missing / invalid shared secret. + * + * @author Longze Chen + * @since 26.1.0 + */ +@Slf4j +@Controller +@RequiredArgsConstructor +public class OrcidTokenRevocationController { + + public static final String BASE_URL = "/osf/orcid"; + + public static final String REVOKE_URL = BASE_URL + "/revoke"; + + private static final String BEARER_PREFIX = "Bearer "; + + private final OsfOrcidTokenDao osfOrcidTokenDao; + + private final String sharedSecret; + + private final String orcidRevokeUrl; + + private final String orcidClientId; + + private final String orcidClientSecret; + + /** + * Handle a revocation request from OSF. + * + * @param authorizationHeader the {@code Authorization: Bearer } header + * @param request the request body, expected to carry an {@code orcid_id} + * @return {@code 401} if the shared secret is missing/invalid, {@code 400} if {@code orcid_id} is missing, + * otherwise {@code 204} whether or not a stored token was found + */ + @PostMapping(path = REVOKE_URL, produces = MediaType.APPLICATION_JSON_VALUE) + @ResponseBody + public ResponseEntity revoke( + @RequestHeader(value = "Authorization", required = false) final String authorizationHeader, + @RequestBody(required = false) final OrcidTokenRevocationRequest request + ) { + if (!isAuthorized(authorizationHeader)) { + LOGGER.warn("Rejected ORCID token revocation request: missing or invalid shared secret"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + final String orcidId = request == null ? null : request.getOrcidId(); + if (StringUtils.isBlank(orcidId)) { + LOGGER.warn("Rejected ORCID token revocation request: missing orcid_id"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + final OsfOrcidToken token = osfOrcidTokenDao.findByOrcidId(orcidId); + if (token == null) { + LOGGER.debug("No stored ORCID token found for ORCID iD [{}]; nothing to revoke.", orcidId); + return ResponseEntity.noContent().build(); + } + OrcidTokenRevocationClient.revoke(orcidRevokeUrl, orcidClientId, orcidClientSecret, token.getAccessToken()); + osfOrcidTokenDao.deleteByOrcidId(orcidId); + LOGGER.info("Revoked and removed stored ORCID token for ORCID iD [{}]", orcidId); + return ResponseEntity.noContent().build(); + } + + private boolean isAuthorized(final String authorizationHeader) { + if (StringUtils.isBlank(sharedSecret) + || StringUtils.isBlank(authorizationHeader) + || !authorizationHeader.startsWith(BEARER_PREFIX)) { + return false; + } + final String provided = authorizationHeader.substring(BEARER_PREFIX.length()).trim(); + return MessageDigest.isEqual( + provided.getBytes(StandardCharsets.UTF_8), + sharedSecret.getBytes(StandardCharsets.UTF_8) + ); + } +} diff --git a/src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationRequest.java b/src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationRequest.java new file mode 100644 index 00000000..50313e1a --- /dev/null +++ b/src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationRequest.java @@ -0,0 +1,22 @@ +package io.cos.cas.osf.web.rest; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * This is {@link OrcidTokenRevocationRequest}, the JSON request body for {@code POST /osf/orcid/revoke}. + * + * @author Longze Chen + * @since 26.1.0 + */ +@Getter +@Setter +@NoArgsConstructor +public class OrcidTokenRevocationRequest { + + @JsonProperty("orcid_id") + private String orcidId; +} diff --git a/src/main/java/org/apereo/cas/configuration/model/core/authentication/AuthenticationProperties.java b/src/main/java/org/apereo/cas/configuration/model/core/authentication/AuthenticationProperties.java index d8d7b1fd..16e1a99b 100644 --- a/src/main/java/org/apereo/cas/configuration/model/core/authentication/AuthenticationProperties.java +++ b/src/main/java/org/apereo/cas/configuration/model/core/authentication/AuthenticationProperties.java @@ -1,6 +1,7 @@ package org.apereo.cas.configuration.model.core.authentication; import io.cos.cas.osf.configuration.model.OsfApiProperties; +import io.cos.cas.osf.configuration.model.OsfOrcidRevocationProperties; import io.cos.cas.osf.configuration.model.OsfPostgresAuthenticationProperties; import io.cos.cas.osf.configuration.model.OsfUrlProperties; @@ -119,6 +120,12 @@ public class AuthenticationProperties implements Serializable { @NestedConfigurationProperty private OsfPostgresAuthenticationProperties osfPostgres = new OsfPostgresAuthenticationProperties(); + /** + * OSF ORCID token revocation settings. + */ + @NestedConfigurationProperty + private OsfOrcidRevocationProperties osfOrcidRevocation = new OsfOrcidRevocationProperties(); + /** * Groovy authentication settings. */ diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories index c702c10a..22d2958a 100644 --- a/src/main/resources/META-INF/spring.factories +++ b/src/main/resources/META-INF/spring.factories @@ -1,8 +1,11 @@ org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ io.cos.cas.oauth.config.OsfPostgresServiceRegistryConfiguration,\ io.cos.cas.osf.config.JpaOsfDaoConfiguration,\ + io.cos.cas.osf.config.OrcidTokenJpaConfiguration,\ io.cos.cas.osf.config.OsfCasCoreAuthenticationMetadataConfiguration,\ io.cos.cas.osf.config.OsfPostgresAuthenticationEventExecutionPlanConfiguration,\ + io.cos.cas.osf.config.OrcidTokenCaptureAuthenticationEventExecutionPlanConfiguration,\ io.cos.cas.osf.web.config.OsfCasSupportActionsConfiguration,\ + io.cos.cas.osf.web.config.OrcidTokenRevocationWebConfiguration,\ io.cos.cas.osf.web.flow.config.OsfCasCoreWebflowConfiguration,\ io.cos.cas.osf.web.flow.config.OsfCasWebflowContextConfiguration From b89072cd5ac6389731546dd4c63ea9ca5dfac1b0 Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Wed, 5 Aug 2026 12:46:48 +0200 Subject: [PATCH 2/4] Add ORCID side down handling --- .../rest/OrcidTokenRevocationController.java | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationController.java b/src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationController.java index 918bccf8..094e1227 100644 --- a/src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationController.java +++ b/src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationController.java @@ -28,9 +28,12 @@ * (a fresh internal-service auth boundary, since an ORCID iD is not itself a secret, unlike client id / client secret * pairs used elsewhere). * - * Behavior, regardless of whether a stored token is found: the local row is always removed and {@code HTTP 204} is - * returned for any successfully processed request (matching "removes regardless of outcome"); {@code 401} is - * returned only for a missing / invalid shared secret. + * Behavior: if no token is stored for the given ORCID iD, this is a no-op ({@code 204}). If a token is stored, the + * local row is deleted (and {@code 204} returned) only once ORCID itself confirms the revocation ({@code HTTP 200}). + * If ORCID's call fails for any reason (unreachable, timeout, non-200 response), the local row is deliberately kept + * so the revocation can be retried later, and {@code 502} is returned — deleting on a failed revoke would strand the + * grant live on ORCID's side with no record left in CAS to retry against, defeating the point of this endpoint. + * {@code 401} is returned only for a missing / invalid shared secret. * * @author Longze Chen * @since 26.1.0 @@ -62,7 +65,8 @@ public class OrcidTokenRevocationController { * @param authorizationHeader the {@code Authorization: Bearer } header * @param request the request body, expected to carry an {@code orcid_id} * @return {@code 401} if the shared secret is missing/invalid, {@code 400} if {@code orcid_id} is missing, - * otherwise {@code 204} whether or not a stored token was found + * {@code 204} if there was nothing to revoke or ORCID confirmed the revocation, {@code 502} if ORCID's + * revocation call itself failed (the local row is kept in this case, for a later retry) */ @PostMapping(path = REVOKE_URL, produces = MediaType.APPLICATION_JSON_VALUE) @ResponseBody @@ -84,7 +88,16 @@ public ResponseEntity revoke( LOGGER.debug("No stored ORCID token found for ORCID iD [{}]; nothing to revoke.", orcidId); return ResponseEntity.noContent().build(); } - OrcidTokenRevocationClient.revoke(orcidRevokeUrl, orcidClientId, orcidClientSecret, token.getAccessToken()); + final boolean revoked = OrcidTokenRevocationClient.revoke( + orcidRevokeUrl, orcidClientId, orcidClientSecret, token.getAccessToken() + ); + if (!revoked) { + LOGGER.warn( + "ORCID revocation call failed for ORCID iD [{}]; keeping the stored token for a later retry.", + orcidId + ); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build(); + } osfOrcidTokenDao.deleteByOrcidId(orcidId); LOGGER.info("Revoked and removed stored ORCID token for ORCID iD [{}]", orcidId); return ResponseEntity.noContent().build(); From 9c87cc214af1d5acad2037c57624aeb158c33f4e Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Fri, 7 Aug 2026 17:29:42 +0200 Subject: [PATCH 3/4] Update revoke ORCID workflow --- etc/cas/config/cas.properties | 6 - ...kenCaptureAuthenticationPostProcessor.java | 67 +++++----- .../support/OrcidTokenRevocationClient.java | 73 ----------- ...cationEventExecutionPlanConfiguration.java | 12 +- .../config/OrcidTokenJpaConfiguration.java | 97 -------------- .../model/OsfOrcidRevocationProperties.java | 36 ----- .../cos/cas/osf/dao/JpaOsfOrcidTokenDao.java | 72 ---------- .../io/cos/cas/osf/dao/OsfOrcidTokenDao.java | 41 ------ .../cos/cas/osf/orcidtoken/OsfOrcidToken.java | 86 ------------ .../OsfOrcidTokenCryptoConverter.java | 29 ---- .../util/crypto/OrcidTokenCipherExecutor.java | 124 ------------------ .../OrcidTokenRevocationWebConfiguration.java | 45 ------- .../rest/OrcidTokenRevocationController.java | 118 ----------------- .../web/rest/OrcidTokenRevocationRequest.java | 22 ---- .../AuthenticationProperties.java | 7 - 15 files changed, 36 insertions(+), 799 deletions(-) delete mode 100644 src/main/java/io/cos/cas/osf/authentication/support/OrcidTokenRevocationClient.java delete mode 100644 src/main/java/io/cos/cas/osf/config/OrcidTokenJpaConfiguration.java delete mode 100644 src/main/java/io/cos/cas/osf/configuration/model/OsfOrcidRevocationProperties.java delete mode 100644 src/main/java/io/cos/cas/osf/dao/JpaOsfOrcidTokenDao.java delete mode 100644 src/main/java/io/cos/cas/osf/dao/OsfOrcidTokenDao.java delete mode 100644 src/main/java/io/cos/cas/osf/orcidtoken/OsfOrcidToken.java delete mode 100644 src/main/java/io/cos/cas/osf/orcidtoken/OsfOrcidTokenCryptoConverter.java delete mode 100644 src/main/java/io/cos/cas/osf/util/crypto/OrcidTokenCipherExecutor.java delete mode 100644 src/main/java/io/cos/cas/osf/web/config/OrcidTokenRevocationWebConfiguration.java delete mode 100644 src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationController.java delete mode 100644 src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationRequest.java diff --git a/etc/cas/config/cas.properties b/etc/cas/config/cas.properties index 0d9fb5c5..dba0355a 100644 --- a/etc/cas/config/cas.properties +++ b/etc/cas/config/cas.properties @@ -267,12 +267,6 @@ cas.authn.pac4j.orcid.client-name=orcid cas.authn.pac4j.orcid.enabled=true cas.authn.pac4j.orcid.callback-url-type=QUERY_PARAMETER # -# ORCID Token Revocation: allows OSF to ask CAS to revoke a stored ORCID OAuth token (GDPR delete) -# -cas.authn.osf-orcid-revocation.revoke-url=${OAUTH_ORCID_REVOKE_URL:https://orcid.org/oauth/revoke} -cas.authn.osf-orcid-revocation.shared-secret=${OSF_ORCID_REVOKE_SHARED_SECRET:} -cas.authn.osf-orcid-revocation.token-encryption-key=${OSF_ORCID_TOKEN_ENCRYPTION_KEY:} -# # Delegation Client: CAS # cas.authn.pac4j.cas[0].login-url=${CAS_CORD_LOGIN_URL:https://bprdeis.cord.edu:8443/cas/login} diff --git a/src/main/java/io/cos/cas/osf/authentication/postprocessor/OrcidTokenCaptureAuthenticationPostProcessor.java b/src/main/java/io/cos/cas/osf/authentication/postprocessor/OrcidTokenCaptureAuthenticationPostProcessor.java index 5cbcee69..f1472d20 100644 --- a/src/main/java/io/cos/cas/osf/authentication/postprocessor/OrcidTokenCaptureAuthenticationPostProcessor.java +++ b/src/main/java/io/cos/cas/osf/authentication/postprocessor/OrcidTokenCaptureAuthenticationPostProcessor.java @@ -1,9 +1,5 @@ package io.cos.cas.osf.authentication.postprocessor; -import io.cos.cas.osf.authentication.support.OrcidTokenRevocationClient; -import io.cos.cas.osf.dao.OsfOrcidTokenDao; -import io.cos.cas.osf.orcidtoken.OsfOrcidToken; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -14,28 +10,36 @@ import org.apereo.cas.authentication.AuthenticationTransaction; import org.apereo.cas.authentication.Credential; import org.apereo.cas.authentication.principal.ClientCredential; +import org.apereo.cas.authentication.principal.Principal; +import org.apereo.cas.authentication.principal.PrincipalFactory; +import org.apereo.cas.authentication.principal.PrincipalFactoryUtils; import org.pac4j.core.profile.CommonProfile; import org.pac4j.oauth.profile.orcid.OrcidProfile; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + /** * This is {@link OrcidTokenCaptureAuthenticationPostProcessor}. * - * Captures the ORCID OAuth access token on a successful ORCID login (via pac4j delegated authentication) and stores - * it in the writable {@code osf_orcid_oauth_token} table, so that OSF can later ask CAS to revoke it (see - * {@code OrcidTokenRevocationController}, used on GDPR delete). + * Captures the ORCID OAuth access token on a successful ORCID login (via pac4j delegated authentication) and attaches + * it to the resolved principal's attributes ({@code orcidId}, {@code orcidAccessToken}), so it is released to OSF + * through the same CAS attribute-release mechanism already used for {@code givenName} / {@code familyName} / + * {@code username} (see e.g. {@code etc/cas/services/local/cas-203948234207100.json}). CAS does not persist the + * token anywhere; OSF is responsible for storing it and, on GDPR delete, revoking it directly against ORCID. * *

Unverified assumption, pending a live spike: this relies on * {@link ClientCredential#getUserProfile()} already being populated (by CAS's pac4j-based authentication handling) - * by the time {@link AuthenticationPostProcessor}s run for the transaction. This has been confirmed against the - * compiled {@code ClientCredential} / {@code OAuth20Profile} API shapes (plain, nullable {@code getUserProfile()} / - * {@code getAccessToken(): String} getters), but the exact point in the CAS 6.2.8 + pac4j 4.1.0 authentication - * pipeline where {@code setUserProfile(...)} is actually invoked could not be confirmed via static inspection alone - * (the relevant handler class is bundled only in the full CAS webapp WAR, not the thin support/api jars used to - * develop this feature). If {@link #captureOrcidToken(ClientCredential)} logs the DEBUG "no resolved profile yet" - * message on every real ORCID login, this hook needs to move to a different point in the pipeline (the fallback - * discussed in the design doc is capturing inside {@code OsfPrincipalFromNonInteractiveCredentialsAction} instead, - * though as currently written that class also runs before profile resolution and would need further changes).

+ * and {@link AuthenticationBuilder#getPrincipal()} already holding the elected principal by the time + * {@link AuthenticationPostProcessor}s run for the transaction. This has been confirmed against the compiled + * {@code ClientCredential} / {@code OAuth20Profile} / {@code AuthenticationBuilder} API shapes, but the exact point + * in the CAS 6.2.8 + pac4j 4.1.0 authentication pipeline where these are populated could not be confirmed via static + * inspection alone (the relevant handler class is bundled only in the full CAS webapp WAR, not the thin support/api + * jars used to develop this feature). If {@link #captureOrcidToken(ClientCredential, AuthenticationBuilder)} logs the + * DEBUG "no resolved profile yet" or "no principal resolved yet" message on every real ORCID login, this hook needs + * to move to a different point in the pipeline.

* *

Wrapped entirely in try/catch: a failure here must never break a login.

* @@ -46,15 +50,13 @@ @RequiredArgsConstructor public class OrcidTokenCaptureAuthenticationPostProcessor implements AuthenticationPostProcessor { - private final String orcidClientName; - - private final String orcidClientId; + public static final String ATTRIBUTE_ORCID_ID = "orcidId"; - private final String orcidClientSecret; + public static final String ATTRIBUTE_ORCID_ACCESS_TOKEN = "orcidAccessToken"; - private final String orcidRevokeUrl; + private static final PrincipalFactory PRINCIPAL_FACTORY = PrincipalFactoryUtils.newPrincipalFactory(); - private final OsfOrcidTokenDao osfOrcidTokenDao; + private final String orcidClientName; @Override public boolean supports(final Credential credential) { @@ -70,10 +72,10 @@ public void process( transaction.getCredentials().stream() .filter(this::supports) .map(credential -> (ClientCredential) credential) - .forEach(this::captureOrcidToken); + .forEach(credential -> captureOrcidToken(credential, builder)); } - private void captureOrcidToken(final ClientCredential credential) { + private void captureOrcidToken(final ClientCredential credential, final AuthenticationBuilder builder) { try { final CommonProfile profile = credential.getUserProfile(); if (!(profile instanceof OrcidProfile)) { @@ -91,15 +93,16 @@ private void captureOrcidToken(final ClientCredential credential) { LOGGER.warn("ORCID login resolved without an ORCID iD or access token; nothing to capture."); return; } - final OsfOrcidToken existing = osfOrcidTokenDao.findByOrcidId(orcidId); - if (existing != null - && StringUtils.isNotBlank(existing.getAccessToken()) - && !existing.getAccessToken().equals(accessToken)) { - LOGGER.debug("Reconnect detected for ORCID iD [{}]; revoking the previous token before replacing it.", orcidId); - OrcidTokenRevocationClient.revoke(orcidRevokeUrl, orcidClientId, orcidClientSecret, existing.getAccessToken()); + final Principal principal = builder.getPrincipal(); + if (principal == null) { + LOGGER.debug("No principal resolved yet on the authentication builder; skipping ORCID token capture."); + return; } - osfOrcidTokenDao.upsertToken(orcidId, accessToken, null, null); - LOGGER.info("Captured ORCID OAuth token for ORCID iD [{}]", orcidId); + final Map> attributes = new LinkedHashMap<>(principal.getAttributes()); + attributes.put(ATTRIBUTE_ORCID_ID, List.of(orcidId)); + attributes.put(ATTRIBUTE_ORCID_ACCESS_TOKEN, List.of(accessToken)); + builder.setPrincipal(PRINCIPAL_FACTORY.createPrincipal(principal.getId(), attributes)); + LOGGER.info("Attached ORCID token attributes to principal for ORCID iD [{}]", orcidId); } catch (final Exception e) { LOGGER.warn("Failed to capture ORCID OAuth token; login proceeds unaffected. Error: {}", e.getMessage()); LOGGER.debug("Full stack trace of the ORCID token capture failure:", e); diff --git a/src/main/java/io/cos/cas/osf/authentication/support/OrcidTokenRevocationClient.java b/src/main/java/io/cos/cas/osf/authentication/support/OrcidTokenRevocationClient.java deleted file mode 100644 index 6e681053..00000000 --- a/src/main/java/io/cos/cas/osf/authentication/support/OrcidTokenRevocationClient.java +++ /dev/null @@ -1,73 +0,0 @@ -package io.cos.cas.osf.authentication.support; - -import lombok.extern.slf4j.Slf4j; - -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.client.fluent.Form; -import org.apache.http.client.fluent.Request; - -import java.io.IOException; - -/** - * This is {@link OrcidTokenRevocationClient}. - * - * Calls ORCID's own OAuth revocation endpoint ({@code POST https://orcid.org/oauth/revoke}). Used both by - * {@code OrcidTokenCaptureAuthenticationPostProcessor} (best-effort revoke-then-replace on reconnect) and by - * {@code OrcidTokenRevocationController} (revocation triggered by OSF, e.g. on GDPR delete). - * - * Per ORCID's API docs, revoking either the access token or the refresh token revokes the pair, and success is - * {@code HTTP 200 OK} with an empty body. - * - * @author Longze Chen - * @since 26.1.0 - */ -@Slf4j -public final class OrcidTokenRevocationClient { - - private static final int CONNECT_AND_SOCKET_TIMEOUT_IN_MILLISECONDS = 5000; - - private OrcidTokenRevocationClient() { - } - - /** - * Best-effort revoke a token against ORCID. Never throws; logs and returns {@code false} on any failure. - * - * @param revokeUrl ORCID's OAuth revocation endpoint - * @param clientId CAS's ORCID OAuth client id - * @param clientSecret CAS's ORCID OAuth client secret - * @param token the access or refresh token to revoke - * @return {@code true} if ORCID responded with {@code HTTP 200}, {@code false} otherwise - */ - public static boolean revoke( - final String revokeUrl, - final String clientId, - final String clientSecret, - final String token - ) { - try { - final HttpResponse response = Request.Post(revokeUrl) - .connectTimeout(CONNECT_AND_SOCKET_TIMEOUT_IN_MILLISECONDS) - .socketTimeout(CONNECT_AND_SOCKET_TIMEOUT_IN_MILLISECONDS) - .bodyForm( - Form.form() - .add("client_id", clientId) - .add("client_secret", clientSecret) - .add("token", token) - .build() - ) - .execute() - .returnResponse(); - final int statusCode = response.getStatusLine().getStatusCode(); - if (statusCode == HttpStatus.SC_OK) { - LOGGER.debug("Successfully revoked ORCID token against [{}]", revokeUrl); - return true; - } - LOGGER.warn("ORCID token revocation against [{}] returned unexpected status [{}]", revokeUrl, statusCode); - return false; - } catch (final IOException e) { - LOGGER.warn("Failed to revoke ORCID token against [{}]: {}", revokeUrl, e.getMessage()); - return false; - } - } -} diff --git a/src/main/java/io/cos/cas/osf/config/OrcidTokenCaptureAuthenticationEventExecutionPlanConfiguration.java b/src/main/java/io/cos/cas/osf/config/OrcidTokenCaptureAuthenticationEventExecutionPlanConfiguration.java index 22812572..6e83c97c 100644 --- a/src/main/java/io/cos/cas/osf/config/OrcidTokenCaptureAuthenticationEventExecutionPlanConfiguration.java +++ b/src/main/java/io/cos/cas/osf/config/OrcidTokenCaptureAuthenticationEventExecutionPlanConfiguration.java @@ -1,7 +1,6 @@ package io.cos.cas.osf.config; import io.cos.cas.osf.authentication.postprocessor.OrcidTokenCaptureAuthenticationPostProcessor; -import io.cos.cas.osf.dao.OsfOrcidTokenDao; import lombok.extern.slf4j.Slf4j; @@ -9,9 +8,7 @@ import org.apereo.cas.authentication.AuthenticationPostProcessor; import org.apereo.cas.configuration.CasConfigurationProperties; -import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -34,18 +31,11 @@ public class OrcidTokenCaptureAuthenticationEventExecutionPlanConfiguration { @Autowired private CasConfigurationProperties casProperties; - @Autowired - private ObjectProvider osfOrcidTokenDao; - @ConditionalOnMissingBean(name = "orcidTokenCaptureAuthenticationPostProcessor") @Bean public AuthenticationPostProcessor orcidTokenCaptureAuthenticationPostProcessor() { return new OrcidTokenCaptureAuthenticationPostProcessor( - casProperties.getAuthn().getPac4j().getOrcid().getClientName(), - casProperties.getAuthn().getPac4j().getOrcid().getId(), - casProperties.getAuthn().getPac4j().getOrcid().getSecret(), - casProperties.getAuthn().getOsfOrcidRevocation().getRevokeUrl(), - osfOrcidTokenDao.getObject() + casProperties.getAuthn().getPac4j().getOrcid().getClientName() ); } diff --git a/src/main/java/io/cos/cas/osf/config/OrcidTokenJpaConfiguration.java b/src/main/java/io/cos/cas/osf/config/OrcidTokenJpaConfiguration.java deleted file mode 100644 index a94bd020..00000000 --- a/src/main/java/io/cos/cas/osf/config/OrcidTokenJpaConfiguration.java +++ /dev/null @@ -1,97 +0,0 @@ -package io.cos.cas.osf.config; - -import io.cos.cas.osf.dao.JpaOsfOrcidTokenDao; -import io.cos.cas.osf.dao.OsfOrcidTokenDao; -import io.cos.cas.osf.util.crypto.OrcidTokenCipherExecutor; - -import org.apereo.cas.configuration.CasConfigurationProperties; -import org.apereo.cas.configuration.model.support.jpa.JpaConfigurationContext; -import org.apereo.cas.configuration.support.JpaBeans; -import org.apereo.cas.jpa.JpaBeanFactory; -import org.apereo.cas.util.spring.ApplicationContextProvider; - -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.ApplicationContext; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Lazy; -import org.springframework.orm.jpa.JpaTransactionManager; -import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; -import org.springframework.transaction.PlatformTransactionManager; - -import javax.annotation.PostConstruct; -import javax.persistence.EntityManagerFactory; -import javax.sql.DataSource; -import java.util.List; - -/** - * This is {@link OrcidTokenJpaConfiguration}. - * - * Configures a second, writable JPA persistence unit dedicated to the {@code osf_orcid_oauth_token} table. This is - * intentionally separate from {@link JpaOsfDaoConfiguration}, which is read-only at the driver level - * ({@code cas.authn.osf-postgres.jpa.url} is opened with {@code readOnly=true&readOnlyMode=always}) and cannot be - * used to persist new state. Instead, this context reuses the connection settings of CAS's own writable ticket - * registry database ({@code cas.ticket.registry.jpa.*}, {@code ddl-auto=update}), so Hibernate creates the new table - * automatically on startup with no separate migration required. - * - * @author Longze Chen - * @since 26.1.0 - */ -@Configuration("orcidTokenJpaConfiguration") -@EnableConfigurationProperties(CasConfigurationProperties.class) -public class OrcidTokenJpaConfiguration { - - private static final List ORCID_TOKEN_MODEL_PACKAGES_TO_SCAN = List.of("io.cos.cas.osf.orcidtoken"); - - @Autowired - @Qualifier("jpaBeanFactory") - private ObjectProvider jpaBeanFactory; - - @Autowired - private CasConfigurationProperties casProperties; - - @Autowired - private ApplicationContext applicationContext; - - @PostConstruct - public void initializeOrcidTokenCipher() { - OrcidTokenCipherExecutor.initialize(casProperties.getAuthn().getOsfOrcidRevocation().getTokenEncryptionKey()); - } - - @Lazy - @Bean - public LocalContainerEntityManagerFactoryBean orcidTokenEntityManagerFactory() { - ApplicationContextProvider.holdApplicationContext(applicationContext); - final JpaBeanFactory factory = jpaBeanFactory.getObject(); - final JpaConfigurationContext ctx = new JpaConfigurationContext( - factory.newJpaVendorAdapter(casProperties.getJdbc()), - "orcidTokenContext", - ORCID_TOKEN_MODEL_PACKAGES_TO_SCAN, - orcidTokenDataSource()); - return factory.newEntityManagerFactoryBean(ctx, casProperties.getTicket().getRegistry().getJpa()); - } - - @Bean - public PlatformTransactionManager orcidTokenTransactionManager( - @Qualifier("orcidTokenEntityManagerFactory") final EntityManagerFactory emf - ) { - final JpaTransactionManager mgmr = new JpaTransactionManager(); - mgmr.setEntityManagerFactory(emf); - return mgmr; - } - - @Bean - public DataSource orcidTokenDataSource() { - return JpaBeans.newDataSource(casProperties.getTicket().getRegistry().getJpa()); - } - - @ConditionalOnMissingBean(name = "osfOrcidTokenDao") - @Bean - public OsfOrcidTokenDao osfOrcidTokenDao() { - return new JpaOsfOrcidTokenDao(); - } -} diff --git a/src/main/java/io/cos/cas/osf/configuration/model/OsfOrcidRevocationProperties.java b/src/main/java/io/cos/cas/osf/configuration/model/OsfOrcidRevocationProperties.java deleted file mode 100644 index 1ec09d82..00000000 --- a/src/main/java/io/cos/cas/osf/configuration/model/OsfOrcidRevocationProperties.java +++ /dev/null @@ -1,36 +0,0 @@ -package io.cos.cas.osf.configuration.model; - -import lombok.Getter; -import lombok.Setter; -import lombok.experimental.Accessors; - -import java.io.Serializable; - -/** - * This is {@link OsfOrcidRevocationProperties}. - * - * @author Longze Chen - * @since 26.1.0 - */ -@Getter -@Setter -@Accessors(chain = true) -public class OsfOrcidRevocationProperties implements Serializable { - - private static final long serialVersionUID = -2836917320958203451L; - - /** - * ORCID's OAuth token revocation endpoint. - */ - private String revokeUrl = "https://orcid.org/oauth/revoke"; - - /** - * The shared secret used to authenticate OSF's calls to {@code POST /osf/orcid/revoke}. - */ - private String sharedSecret; - - /** - * The symmetric key used to encrypt / decrypt stored ORCID access and refresh tokens at rest. - */ - private String tokenEncryptionKey; -} diff --git a/src/main/java/io/cos/cas/osf/dao/JpaOsfOrcidTokenDao.java b/src/main/java/io/cos/cas/osf/dao/JpaOsfOrcidTokenDao.java deleted file mode 100644 index 79ae5d07..00000000 --- a/src/main/java/io/cos/cas/osf/dao/JpaOsfOrcidTokenDao.java +++ /dev/null @@ -1,72 +0,0 @@ -package io.cos.cas.osf.dao; - -import io.cos.cas.osf.orcidtoken.OsfOrcidToken; - -import lombok.NoArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import org.springframework.transaction.annotation.Transactional; - -import javax.persistence.EntityManager; -import javax.persistence.PersistenceContext; -import javax.persistence.PersistenceException; -import javax.persistence.TypedQuery; -import javax.validation.constraints.NotNull; - -/** - * This is {@link JpaOsfOrcidTokenDao}. - * - * @author Longze Chen - * @since 26.1.0 - */ -@Slf4j -@NoArgsConstructor -@Transactional(transactionManager = "orcidTokenTransactionManager") -public class JpaOsfOrcidTokenDao implements OsfOrcidTokenDao { - - @NotNull - @PersistenceContext(unitName = "orcidTokenEntityManagerFactory") - private EntityManager entityManager; - - @Override - public OsfOrcidToken findByOrcidId(final String orcidId) { - try { - final TypedQuery query = entityManager.createQuery( - "select t from OsfOrcidToken t where t.orcidId = :orcidId", - OsfOrcidToken.class - ); - query.setParameter("orcidId", orcidId); - return query.getSingleResult(); - } catch (final PersistenceException e) { - return null; - } - } - - @Override - public OsfOrcidToken upsertToken( - final String orcidId, - final String accessToken, - final String refreshToken, - final String scope - ) { - OsfOrcidToken token = findByOrcidId(orcidId); - if (token == null) { - token = new OsfOrcidToken(); - token.setOrcidId(orcidId); - } - token.setAccessToken(accessToken); - token.setRefreshToken(refreshToken); - token.setScope(scope); - return entityManager.merge(token); - } - - @Override - public void deleteByOrcidId(final String orcidId) { - final OsfOrcidToken token = findByOrcidId(orcidId); - if (token != null) { - entityManager.remove(entityManager.contains(token) ? token : entityManager.merge(token)); - } else { - LOGGER.debug("No stored ORCID token found for orcid id [{}]; nothing to delete", orcidId); - } - } -} diff --git a/src/main/java/io/cos/cas/osf/dao/OsfOrcidTokenDao.java b/src/main/java/io/cos/cas/osf/dao/OsfOrcidTokenDao.java deleted file mode 100644 index 57e80b75..00000000 --- a/src/main/java/io/cos/cas/osf/dao/OsfOrcidTokenDao.java +++ /dev/null @@ -1,41 +0,0 @@ -package io.cos.cas.osf.dao; - -import io.cos.cas.osf.orcidtoken.OsfOrcidToken; - -/** - * This is {@link OsfOrcidTokenDao}. - * - * DAO for the writable {@code osf_orcid_oauth_token} table, used to capture ORCID OAuth tokens on login and to - * support CAS-side revocation triggered by OSF (e.g. GDPR delete). - * - * @author Longze Chen - * @since 26.1.0 - */ -public interface OsfOrcidTokenDao { - - /** - * Find the stored token for a given ORCID iD, if any. - * - * @param orcidId the ORCID iD - * @return the stored token, or {@code null} if none exists - */ - OsfOrcidToken findByOrcidId(String orcidId); - - /** - * Insert or update the stored token for a given ORCID iD. - * - * @param orcidId the ORCID iD (natural key) - * @param accessToken the ORCID OAuth access token - * @param refreshToken the ORCID OAuth refresh token, may be {@code null} - * @param scope the granted OAuth scope, may be {@code null} - * @return the persisted token entity - */ - OsfOrcidToken upsertToken(String orcidId, String accessToken, String refreshToken, String scope); - - /** - * Delete the stored token for a given ORCID iD, if any. A no-op if none exists. - * - * @param orcidId the ORCID iD - */ - void deleteByOrcidId(String orcidId); -} diff --git a/src/main/java/io/cos/cas/osf/orcidtoken/OsfOrcidToken.java b/src/main/java/io/cos/cas/osf/orcidtoken/OsfOrcidToken.java deleted file mode 100644 index d0ca6bd1..00000000 --- a/src/main/java/io/cos/cas/osf/orcidtoken/OsfOrcidToken.java +++ /dev/null @@ -1,86 +0,0 @@ -package io.cos.cas.osf.orcidtoken; - -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import lombok.ToString; - -import javax.persistence.Column; -import javax.persistence.Convert; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.PrePersist; -import javax.persistence.PreUpdate; -import javax.persistence.Table; -import java.io.Serializable; -import java.util.Date; - -/** - * This is {@link OsfOrcidToken}. - * - * Stores the ORCID OAuth access / refresh token captured on ORCID login, keyed by ORCID iD, so that OSF can later ask - * CAS to revoke it (e.g. on GDPR delete). Deliberately its own class hierarchy rather than a subclass of - * {@link io.cos.cas.osf.model.AbstractOsfModel}: that base class is tied to the read-only OSF Postgres persistence - * unit ({@code JpaOsfDaoConfiguration}), whereas this entity lives in the separate, writable persistence unit - * defined by {@code OrcidTokenJpaConfiguration}. - * - *

Lives in {@code io.cos.cas.osf.orcidtoken} rather than under {@code io.cos.cas.osf.model} on purpose: - * {@code JpaOsfDaoConfiguration.jpaOsfDaoModelPackagesToScan()} returns the (undeduplicated) package name of every - * {@code AbstractOsfModel} subtype it finds, and Spring/Hibernate's package scanning recurses into subpackages — so - * nesting this under {@code io.cos.cas.osf.model} caused {@link OsfOrcidTokenCryptoConverter} to be swept into the - * read-only persistence unit's scan too (registered once per duplicate package-name entry), which Hibernate rejects - * with {@code AttributeConverter class ... registered multiple times}. Keeping this package outside that subtree - * avoids the collision entirely.

- * - * @author Longze Chen - * @since 26.1.0 - */ -@Entity -@Table(name = "osf_orcid_oauth_token") -@NoArgsConstructor -@Getter -@Setter -@ToString(exclude = {"accessToken", "refreshToken"}) -public class OsfOrcidToken implements Serializable { - - private static final long serialVersionUID = 2778546873719340158L; - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id", nullable = false) - private Long id; - - @Column(name = "orcid_id", nullable = false, unique = true) - private String orcidId; - - @Convert(converter = OsfOrcidTokenCryptoConverter.class) - @Column(name = "access_token", nullable = false, length = 4096) - private String accessToken; - - @Convert(converter = OsfOrcidTokenCryptoConverter.class) - @Column(name = "refresh_token", length = 4096) - private String refreshToken; - - @Column(name = "scope") - private String scope; - - @Column(name = "date_created", nullable = false) - private Date dateCreated; - - @Column(name = "date_modified", nullable = false) - private Date dateModified; - - @PrePersist - protected void onCreate() { - final Date now = new Date(); - this.dateCreated = now; - this.dateModified = now; - } - - @PreUpdate - protected void onUpdate() { - this.dateModified = new Date(); - } -} diff --git a/src/main/java/io/cos/cas/osf/orcidtoken/OsfOrcidTokenCryptoConverter.java b/src/main/java/io/cos/cas/osf/orcidtoken/OsfOrcidTokenCryptoConverter.java deleted file mode 100644 index e5f8e1a6..00000000 --- a/src/main/java/io/cos/cas/osf/orcidtoken/OsfOrcidTokenCryptoConverter.java +++ /dev/null @@ -1,29 +0,0 @@ -package io.cos.cas.osf.orcidtoken; - -import io.cos.cas.osf.util.crypto.OrcidTokenCipherExecutor; - -import javax.persistence.AttributeConverter; -import javax.persistence.Converter; - -/** - * This is {@link OsfOrcidTokenCryptoConverter}. - * - * Transparently encrypts / decrypts {@link OsfOrcidToken#accessToken} and {@link OsfOrcidToken#refreshToken} so that - * ORCID's long-lived OAuth tokens are never persisted to {@code osf_orcid_oauth_token} in plaintext. - * - * @author Longze Chen - * @since 26.1.0 - */ -@Converter -public class OsfOrcidTokenCryptoConverter implements AttributeConverter { - - @Override - public String convertToDatabaseColumn(final String attribute) { - return OrcidTokenCipherExecutor.encrypt(attribute); - } - - @Override - public String convertToEntityAttribute(final String dbData) { - return OrcidTokenCipherExecutor.decrypt(dbData); - } -} diff --git a/src/main/java/io/cos/cas/osf/util/crypto/OrcidTokenCipherExecutor.java b/src/main/java/io/cos/cas/osf/util/crypto/OrcidTokenCipherExecutor.java deleted file mode 100644 index 97ec559f..00000000 --- a/src/main/java/io/cos/cas/osf/util/crypto/OrcidTokenCipherExecutor.java +++ /dev/null @@ -1,124 +0,0 @@ -package io.cos.cas.osf.util.crypto; - -import lombok.extern.slf4j.Slf4j; - -import org.apache.commons.lang3.StringUtils; - -import javax.crypto.Cipher; -import javax.crypto.spec.GCMParameterSpec; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.util.Base64; - -/** - * This is {@link OrcidTokenCipherExecutor}. - * - * Encrypts / decrypts the ORCID access and refresh tokens stored in {@code osf_orcid_oauth_token} at rest, using - * AES/GCM with a key derived from {@code cas.authn.osf-orcid-revocation.token-encryption-key}. JPA - * {@link javax.persistence.AttributeConverter} instances are instantiated directly by the persistence provider - * (not by Spring), so the raw configured secret is stashed here as a static field once, during - * {@code OrcidTokenJpaConfiguration} bean initialization, and read from here by - * {@link io.cos.cas.osf.orcidtoken.OsfOrcidTokenCryptoConverter}. - * - * @author Longze Chen - * @since 26.1.0 - */ -@Slf4j -public final class OrcidTokenCipherExecutor { - - private static final String CIPHER_TRANSFORMATION = "AES/GCM/NoPadding"; - - private static final String KEY_ALGORITHM = "AES"; - - private static final int GCM_IV_LENGTH_IN_BYTES = 12; - - private static final int GCM_TAG_LENGTH_IN_BITS = 128; - - private static volatile SecretKeySpec secretKeySpec; - - private OrcidTokenCipherExecutor() { - } - - /** - * Initialize the static encryption key from the configured shared secret. Safe to call more than once (e.g. on - * context refresh); the last value wins. - * - * @param rawKey the configured {@code cas.authn.osf-orcid-revocation.token-encryption-key} - */ - public static void initialize(final String rawKey) { - if (StringUtils.isBlank(rawKey)) { - LOGGER.warn("ORCID token encryption key is not configured; ORCID token storage will fail until it is set."); - secretKeySpec = null; - return; - } - try { - final MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); - final byte[] normalizedKey = sha256.digest(rawKey.getBytes(StandardCharsets.UTF_8)); - secretKeySpec = new SecretKeySpec(normalizedKey, KEY_ALGORITHM); - } catch (final NoSuchAlgorithmException e) { - throw new IllegalStateException("Unable to initialize ORCID token cipher: " + e.getMessage(), e); - } - } - - /** - * Encrypt a plaintext value, returning a Base64 string of {@code iv || ciphertext || tag}. - * - * @param plainText the value to encrypt - * @return the encrypted value, or {@code null} if the input is {@code null} - */ - public static String encrypt(final String plainText) { - if (plainText == null) { - return null; - } - try { - final byte[] iv = new byte[GCM_IV_LENGTH_IN_BYTES]; - new SecureRandom().nextBytes(iv); - final Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION); - cipher.init(Cipher.ENCRYPT_MODE, requireKey(), new GCMParameterSpec(GCM_TAG_LENGTH_IN_BITS, iv)); - final byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); - final byte[] combined = new byte[iv.length + cipherText.length]; - System.arraycopy(iv, 0, combined, 0, iv.length); - System.arraycopy(cipherText, 0, combined, iv.length, cipherText.length); - return Base64.getEncoder().encodeToString(combined); - } catch (final Exception e) { - throw new IllegalStateException("Failed to encrypt ORCID token: " + e.getMessage(), e); - } - } - - /** - * Decrypt a value previously produced by {@link #encrypt(String)}. - * - * @param encoded the Base64-encoded {@code iv || ciphertext || tag} - * @return the decrypted plaintext, or {@code null} if the input is {@code null} - */ - public static String decrypt(final String encoded) { - if (encoded == null) { - return null; - } - try { - final byte[] combined = Base64.getDecoder().decode(encoded); - final byte[] iv = new byte[GCM_IV_LENGTH_IN_BYTES]; - System.arraycopy(combined, 0, iv, 0, iv.length); - final byte[] cipherText = new byte[combined.length - iv.length]; - System.arraycopy(combined, iv.length, cipherText, 0, cipherText.length); - final Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION); - cipher.init(Cipher.DECRYPT_MODE, requireKey(), new GCMParameterSpec(GCM_TAG_LENGTH_IN_BITS, iv)); - return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8); - } catch (final Exception e) { - throw new IllegalStateException("Failed to decrypt ORCID token: " + e.getMessage(), e); - } - } - - private static SecretKeySpec requireKey() { - final SecretKeySpec key = secretKeySpec; - if (key == null) { - throw new IllegalStateException( - "ORCID token cipher is not initialized; check cas.authn.osf-orcid-revocation.token-encryption-key" - ); - } - return key; - } -} diff --git a/src/main/java/io/cos/cas/osf/web/config/OrcidTokenRevocationWebConfiguration.java b/src/main/java/io/cos/cas/osf/web/config/OrcidTokenRevocationWebConfiguration.java deleted file mode 100644 index 6d07cba6..00000000 --- a/src/main/java/io/cos/cas/osf/web/config/OrcidTokenRevocationWebConfiguration.java +++ /dev/null @@ -1,45 +0,0 @@ -package io.cos.cas.osf.web.config; - -import io.cos.cas.osf.dao.OsfOrcidTokenDao; -import io.cos.cas.osf.web.rest.OrcidTokenRevocationController; - -import org.apereo.cas.configuration.CasConfigurationProperties; - -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * This is {@link OrcidTokenRevocationWebConfiguration}. - * - * Registers {@link OrcidTokenRevocationController} as a Spring MVC controller bean; CAS discovers {@code @Controller} - * beans regardless of how they were registered (auto-config bean method here, not classpath component-scan). - * - * @author Longze Chen - * @since 26.1.0 - */ -@Configuration("orcidTokenRevocationWebConfiguration") -@EnableConfigurationProperties(CasConfigurationProperties.class) -public class OrcidTokenRevocationWebConfiguration { - - @Autowired - private CasConfigurationProperties casProperties; - - @Autowired - private ObjectProvider osfOrcidTokenDao; - - @ConditionalOnMissingBean(name = "orcidTokenRevocationController") - @Bean - public OrcidTokenRevocationController orcidTokenRevocationController() { - return new OrcidTokenRevocationController( - osfOrcidTokenDao.getObject(), - casProperties.getAuthn().getOsfOrcidRevocation().getSharedSecret(), - casProperties.getAuthn().getOsfOrcidRevocation().getRevokeUrl(), - casProperties.getAuthn().getPac4j().getOrcid().getId(), - casProperties.getAuthn().getPac4j().getOrcid().getSecret() - ); - } -} diff --git a/src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationController.java b/src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationController.java deleted file mode 100644 index 094e1227..00000000 --- a/src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationController.java +++ /dev/null @@ -1,118 +0,0 @@ -package io.cos.cas.osf.web.rest; - -import io.cos.cas.osf.authentication.support.OrcidTokenRevocationClient; -import io.cos.cas.osf.dao.OsfOrcidTokenDao; -import io.cos.cas.osf.orcidtoken.OsfOrcidToken; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import org.apache.commons.lang3.StringUtils; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.ResponseBody; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; - -/** - * This is {@link OrcidTokenRevocationController}. - * - * Handles {@code POST /osf/orcid/revoke}, the endpoint OSF calls (from {@code OSFUser._clear_identifying_information()} - * on GDPR delete) to ask CAS to revoke a stored ORCID OAuth token. Authenticated via a shared-secret bearer header - * (a fresh internal-service auth boundary, since an ORCID iD is not itself a secret, unlike client id / client secret - * pairs used elsewhere). - * - * Behavior: if no token is stored for the given ORCID iD, this is a no-op ({@code 204}). If a token is stored, the - * local row is deleted (and {@code 204} returned) only once ORCID itself confirms the revocation ({@code HTTP 200}). - * If ORCID's call fails for any reason (unreachable, timeout, non-200 response), the local row is deliberately kept - * so the revocation can be retried later, and {@code 502} is returned — deleting on a failed revoke would strand the - * grant live on ORCID's side with no record left in CAS to retry against, defeating the point of this endpoint. - * {@code 401} is returned only for a missing / invalid shared secret. - * - * @author Longze Chen - * @since 26.1.0 - */ -@Slf4j -@Controller -@RequiredArgsConstructor -public class OrcidTokenRevocationController { - - public static final String BASE_URL = "/osf/orcid"; - - public static final String REVOKE_URL = BASE_URL + "/revoke"; - - private static final String BEARER_PREFIX = "Bearer "; - - private final OsfOrcidTokenDao osfOrcidTokenDao; - - private final String sharedSecret; - - private final String orcidRevokeUrl; - - private final String orcidClientId; - - private final String orcidClientSecret; - - /** - * Handle a revocation request from OSF. - * - * @param authorizationHeader the {@code Authorization: Bearer } header - * @param request the request body, expected to carry an {@code orcid_id} - * @return {@code 401} if the shared secret is missing/invalid, {@code 400} if {@code orcid_id} is missing, - * {@code 204} if there was nothing to revoke or ORCID confirmed the revocation, {@code 502} if ORCID's - * revocation call itself failed (the local row is kept in this case, for a later retry) - */ - @PostMapping(path = REVOKE_URL, produces = MediaType.APPLICATION_JSON_VALUE) - @ResponseBody - public ResponseEntity revoke( - @RequestHeader(value = "Authorization", required = false) final String authorizationHeader, - @RequestBody(required = false) final OrcidTokenRevocationRequest request - ) { - if (!isAuthorized(authorizationHeader)) { - LOGGER.warn("Rejected ORCID token revocation request: missing or invalid shared secret"); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - final String orcidId = request == null ? null : request.getOrcidId(); - if (StringUtils.isBlank(orcidId)) { - LOGGER.warn("Rejected ORCID token revocation request: missing orcid_id"); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); - } - final OsfOrcidToken token = osfOrcidTokenDao.findByOrcidId(orcidId); - if (token == null) { - LOGGER.debug("No stored ORCID token found for ORCID iD [{}]; nothing to revoke.", orcidId); - return ResponseEntity.noContent().build(); - } - final boolean revoked = OrcidTokenRevocationClient.revoke( - orcidRevokeUrl, orcidClientId, orcidClientSecret, token.getAccessToken() - ); - if (!revoked) { - LOGGER.warn( - "ORCID revocation call failed for ORCID iD [{}]; keeping the stored token for a later retry.", - orcidId - ); - return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build(); - } - osfOrcidTokenDao.deleteByOrcidId(orcidId); - LOGGER.info("Revoked and removed stored ORCID token for ORCID iD [{}]", orcidId); - return ResponseEntity.noContent().build(); - } - - private boolean isAuthorized(final String authorizationHeader) { - if (StringUtils.isBlank(sharedSecret) - || StringUtils.isBlank(authorizationHeader) - || !authorizationHeader.startsWith(BEARER_PREFIX)) { - return false; - } - final String provided = authorizationHeader.substring(BEARER_PREFIX.length()).trim(); - return MessageDigest.isEqual( - provided.getBytes(StandardCharsets.UTF_8), - sharedSecret.getBytes(StandardCharsets.UTF_8) - ); - } -} diff --git a/src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationRequest.java b/src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationRequest.java deleted file mode 100644 index 50313e1a..00000000 --- a/src/main/java/io/cos/cas/osf/web/rest/OrcidTokenRevocationRequest.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.cos.cas.osf.web.rest; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -/** - * This is {@link OrcidTokenRevocationRequest}, the JSON request body for {@code POST /osf/orcid/revoke}. - * - * @author Longze Chen - * @since 26.1.0 - */ -@Getter -@Setter -@NoArgsConstructor -public class OrcidTokenRevocationRequest { - - @JsonProperty("orcid_id") - private String orcidId; -} diff --git a/src/main/java/org/apereo/cas/configuration/model/core/authentication/AuthenticationProperties.java b/src/main/java/org/apereo/cas/configuration/model/core/authentication/AuthenticationProperties.java index 16e1a99b..d8d7b1fd 100644 --- a/src/main/java/org/apereo/cas/configuration/model/core/authentication/AuthenticationProperties.java +++ b/src/main/java/org/apereo/cas/configuration/model/core/authentication/AuthenticationProperties.java @@ -1,7 +1,6 @@ package org.apereo.cas.configuration.model.core.authentication; import io.cos.cas.osf.configuration.model.OsfApiProperties; -import io.cos.cas.osf.configuration.model.OsfOrcidRevocationProperties; import io.cos.cas.osf.configuration.model.OsfPostgresAuthenticationProperties; import io.cos.cas.osf.configuration.model.OsfUrlProperties; @@ -120,12 +119,6 @@ public class AuthenticationProperties implements Serializable { @NestedConfigurationProperty private OsfPostgresAuthenticationProperties osfPostgres = new OsfPostgresAuthenticationProperties(); - /** - * OSF ORCID token revocation settings. - */ - @NestedConfigurationProperty - private OsfOrcidRevocationProperties osfOrcidRevocation = new OsfOrcidRevocationProperties(); - /** * Groovy authentication settings. */ From 07452381f94fb5ff4f588236dd9d6140753830f9 Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Thu, 13 Aug 2026 13:52:54 +0200 Subject: [PATCH 4/4] update factories --- src/main/resources/META-INF/spring.factories | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories index 22d2958a..8623d99c 100644 --- a/src/main/resources/META-INF/spring.factories +++ b/src/main/resources/META-INF/spring.factories @@ -1,11 +1,9 @@ org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ io.cos.cas.oauth.config.OsfPostgresServiceRegistryConfiguration,\ io.cos.cas.osf.config.JpaOsfDaoConfiguration,\ - io.cos.cas.osf.config.OrcidTokenJpaConfiguration,\ io.cos.cas.osf.config.OsfCasCoreAuthenticationMetadataConfiguration,\ io.cos.cas.osf.config.OsfPostgresAuthenticationEventExecutionPlanConfiguration,\ io.cos.cas.osf.config.OrcidTokenCaptureAuthenticationEventExecutionPlanConfiguration,\ io.cos.cas.osf.web.config.OsfCasSupportActionsConfiguration,\ - io.cos.cas.osf.web.config.OrcidTokenRevocationWebConfiguration,\ io.cos.cas.osf.web.flow.config.OsfCasCoreWebflowConfiguration,\ io.cos.cas.osf.web.flow.config.OsfCasWebflowContextConfiguration