({{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 ###########