From 1c3344b2f781ea532ffe5ea65b7b6aa064af2e7c Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Fri, 31 Jul 2026 16:21:28 +0200 Subject: [PATCH] Add revoke endpoint request for ORCID --- admin/templates/users/user.html | 12 +++++++ framework/auth/cas.py | 16 +++++++++ framework/auth/tasks.py | 11 ++++++ .../0045_osfuser_orcid_token_tracking.py | 27 ++++++++++++++ osf/models/user.py | 24 ++++++++++++- osf_tests/test_user.py | 35 +++++++++++++++++-- website/settings/defaults.py | 1 + 7 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 osf/migrations/0045_osfuser_orcid_token_tracking.py diff --git a/admin/templates/users/user.html b/admin/templates/users/user.html index 5cc9244d484..640bf0f9924 100644 --- a/admin/templates/users/user.html +++ b/admin/templates/users/user.html @@ -126,6 +126,18 @@

User: {{ user.username }} ({{user. {% endif %} + + Initial ORCID Authorization + {{ user.date_orcid_initial_authorized }} + + + Last ORCID Authorization + {{ user.date_orcid_last_authorized }} + + + ORCID Token Reserved + {{ user.orcid_token_stored }} + Registered {{ user.is_registered }} [{{ user.date_registered }}] diff --git a/framework/auth/cas.py b/framework/auth/cas.py index 1084739fdc3..a4d576868a9 100644 --- a/framework/auth/cas.py +++ b/framework/auth/cas.py @@ -28,6 +28,7 @@ class CasHTTPError(CasError): def __init__(self, code, message, headers, content): super().__init__(code, message) + self.message = message self.headers = headers self.content = content @@ -97,6 +98,10 @@ def get_auth_token_revocation_url(self): url = furl(self.BASE_URL).add(path=['oauth2', 'revoke']) return url.url + def get_orcid_token_revocation_url(self): + url = furl(self.BASE_URL).add(path=['osf', 'orcid', 'revoke']) + return url.url + def service_validate(self, ticket, service_url): """ Send request to CAS to validate ticket. @@ -198,6 +203,17 @@ def revoke_tokens(self, payload): else: self._handle_error(resp) + def revoke_orcid_token(self, orcid_id): + url = self.get_orcid_token_revocation_url() + headers = { + 'Authorization': f'Bearer {settings.CAS_ORCID_REVOKE_SHARED_SECRET}', + } + resp = requests.post(url, json={'orcid_id': orcid_id}, headers=headers) + if resp.status_code == 204: + return True + else: + self._handle_error(resp) + def parse_auth_header(header): """ diff --git a/framework/auth/tasks.py b/framework/auth/tasks.py index e0083798911..640003fd994 100644 --- a/framework/auth/tasks.py +++ b/framework/auth/tasks.py @@ -2,6 +2,7 @@ import itertools import logging +from django.utils import timezone from lxml import etree import pytz import requests @@ -54,6 +55,16 @@ def update_affiliation_for_orcid_sso_users(user_id, orcid_id): logger.error(error_message) sentry.log_message(error_message) return + + # Best-effort tracking of ORCID (re)authorization for admin visibility and GDPR-delete triage. + # This reflects that OSF observed a completed ORCID login, not a confirmed CAS-side token write. + now = timezone.now() + if not user.date_orcid_initial_authorized: + user.date_orcid_initial_authorized = now + user.date_orcid_last_authorized = now + user.orcid_token_stored = True + user.save() + institution = check_institution_affiliation(orcid_id) if institution: logger.info(f'Eligible institution affiliation has been found for ORCiD SSO user: ' diff --git a/osf/migrations/0045_osfuser_orcid_token_tracking.py b/osf/migrations/0045_osfuser_orcid_token_tracking.py new file mode 100644 index 00000000000..c4519083a1a --- /dev/null +++ b/osf/migrations/0045_osfuser_orcid_token_tracking.py @@ -0,0 +1,27 @@ +from django.db import migrations, models +import osf.utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0044_notification_scheduled'), + ] + + operations = [ + migrations.AddField( + model_name='osfuser', + name='date_orcid_initial_authorized', + field=osf.utils.fields.NonNaiveDateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='osfuser', + name='date_orcid_last_authorized', + field=osf.utils.fields.NonNaiveDateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='osfuser', + name='orcid_token_stored', + field=models.BooleanField(default=False), + ), + ] diff --git a/osf/models/user.py b/osf/models/user.py index 0218af34692..f8be973d9b5 100644 --- a/osf/models/user.py +++ b/osf/models/user.py @@ -26,7 +26,7 @@ from django.utils import timezone from framework import sentry -from framework.auth import Auth, signals, utils +from framework.auth import Auth, cas, signals, utils from framework.auth.core import generate_verification_key from framework.auth.exceptions import ( ChangePasswordError, @@ -402,6 +402,12 @@ class OSFUser(DirtyFieldsMixin, GuidMixin, BaseModel, AbstractBaseUser, Permissi chronos_user_id = models.TextField(null=True, blank=True, db_index=True) + date_orcid_initial_authorized = NonNaiveDateTimeField(null=True, blank=True) + + date_orcid_last_authorized = NonNaiveDateTimeField(null=True, blank=True) + + orcid_token_stored = models.BooleanField(default=False) + allow_indexing = models.BooleanField(null=True, blank=True, default=None) objects = OSFUserManager() @@ -2166,6 +2172,22 @@ def _clear_identifying_information(self): account.profile_url = None account.save() self.external_accounts.clear() + + # Revoke any ORCID OAuth token CAS holds for this user, so OSF no longer shows as a + # trusted party on the user's ORCID account. Best-effort: never blocks GDPR delete. + orcid_ids = self.external_identity.get('ORCID', {}) + if orcid_ids: + for orcid_id in orcid_ids: + try: + cas.get_client().revoke_orcid_token(orcid_id) + except cas.CasHTTPError as e: + logger.error(f'Unable to revoke ORCID token via CAS for user {self._id}, orcid_id={orcid_id}: {e}') + sentry.log_exception(e) + except Exception as e: + logger.error(f'Unexpected error revoking ORCID token via CAS for user {self._id}, orcid_id={orcid_id}: {e}') + sentry.log_exception(e) + self.orcid_token_stored = False + self.external_identity = {} self.deleted = timezone.now() diff --git a/osf_tests/test_user.py b/osf_tests/test_user.py index 27a447d6681..fed7cc03f17 100644 --- a/osf_tests/test_user.py +++ b/osf_tests/test_user.py @@ -2222,11 +2222,15 @@ def test_gdpr_delete_triggers_share_update_for_public_shared_preprints( assert mock_update_search.called - def test_can_gdpr_delete(self, user): + @mock.patch('framework.auth.cas.CasClient.revoke_orcid_token') + def test_can_gdpr_delete(self, mock_revoke_orcid_token, user): + user.external_identity = {'ORCID': {'fake-orcid-id': 'VERIFIED'}} + user.orcid_token_stored = True + user.save() + user.social = ['fake social'] user.schools = ['fake schools'] user.jobs = ['fake jobs'] - user.external_identity = ['fake external identity'] user.external_accounts.add(ExternalAccountFactory()) user.gdpr_delete() @@ -2241,6 +2245,33 @@ def test_can_gdpr_delete(self, user): assert not user.external_accounts.exists() assert user.is_disabled assert user.deleted is not None + mock_revoke_orcid_token.assert_called_once_with('fake-orcid-id') + assert user.orcid_token_stored is False + + @mock.patch('framework.auth.cas.CasClient.revoke_orcid_token') + def test_gdpr_delete_no_orcid_no_cas_call(self, mock_revoke_orcid_token, user): + assert user.external_identity == {} + + user.gdpr_delete() + + mock_revoke_orcid_token.assert_not_called() + + @mock.patch('framework.auth.cas.CasClient.revoke_orcid_token') + def test_gdpr_delete_orcid_revoke_failure_does_not_block_delete(self, mock_revoke_orcid_token, user): + from framework.auth import cas + mock_revoke_orcid_token.side_effect = cas.CasHTTPError( + code=400, message='Bad Request', headers={}, content=b'', + ) + user.external_identity = {'ORCID': {'fake-orcid-id': 'VERIFIED'}} + user.orcid_token_stored = True + user.save() + + user.gdpr_delete() + + mock_revoke_orcid_token.assert_called_once_with('fake-orcid-id') + assert user.external_identity == {} + assert user.orcid_token_stored is False + assert user.deleted is not None def test_can_gdpr_delete_personal_nodes(self, user): diff --git a/website/settings/defaults.py b/website/settings/defaults.py index 026187948a5..b0ad777a7b0 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -383,6 +383,7 @@ def parent_dir(path): SPAM_SUBMIT_TASK_HARD_TIME_LIMIT = 90 CAS_SERVER_URL = 'http://localhost:8080' +CAS_ORCID_REVOKE_SHARED_SECRET = os.environ.get('CAS_ORCID_REVOKE_SHARED_SECRET', 'changeme') MFR_SERVER_URL = 'http://localhost:7778' ###### ARCHIVER ###########