Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions etc/cas/config/cas.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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).
*
* <p><strong>Unverified assumption, pending a live spike:</strong> 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).</p>
*
* <p>Wrapped entirely in try/catch: a failure here must never break a login.</p>
*
* @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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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> 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());
};
}
}
Original file line number Diff line number Diff line change
@@ -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<String> ORCID_TOKEN_MODEL_PACKAGES_TO_SCAN = List.of("io.cos.cas.osf.orcidtoken");

@Autowired
@Qualifier("jpaBeanFactory")
private ObjectProvider<JpaBeanFactory> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Loading