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