Skip to content

Commit e236531

Browse files
committed
Merge branch 'hotfix/25.15.2'
2 parents a0501f3 + eed6e93 commit e236531

File tree

6 files changed

+172
-5
lines changed

6 files changed

+172
-5
lines changed

CHANGELOG

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO.
44

5+
25.15.2 (2025-09-10)
6+
====================
7+
8+
- Handle duplicate SSO emails during institution SSO
9+
510
25.15.1 (2025-09-04)
611
====================
712

api/institutions/authentication.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
from api.waffle.utils import flag_is_active
1717

1818
from framework import sentry
19-
from framework.auth import get_or_create_institutional_user
19+
from framework.auth import get_or_create_institutional_user, deduplicate_sso_attributes
20+
from framework.auth.exceptions import MultipleSSOEmailError
2021

2122
from osf import features
2223
from osf.exceptions import InstitutionAffiliationStateError
@@ -223,10 +224,16 @@ def authenticate(self, request):
223224
f'sso_email={sso_email}, sso_identity={sso_identity}]',
224225
)
225226

227+
# Deduplicate names first if it is provided
228+
if fullname:
229+
fullname = deduplicate_sso_attributes('fullname', fullname)
230+
if given_name:
231+
given_name = deduplicate_sso_attributes('given_name', given_name)
232+
if family_name:
233+
family_name = deduplicate_sso_attributes('family_name', family_name)
226234
# Use given name and family name to build full name if it is not provided
227235
if given_name and family_name and not fullname:
228236
fullname = given_name + ' ' + family_name
229-
230237
# Non-empty full name is required. Fail the auth and inform sentry if not provided.
231238
if not fullname:
232239
message = f'Institution SSO Error: missing full name ' \
@@ -235,6 +242,14 @@ def authenticate(self, request):
235242
sentry.log_message(message)
236243
raise PermissionDenied(detail='InstitutionSsoMissingUserNames')
237244

245+
# Deduplicate sso email, currently we only handle duplicate sso email instead of multiple sso email
246+
try:
247+
sso_email = deduplicate_sso_attributes('sso_email', sso_email)
248+
except MultipleSSOEmailError:
249+
message = f'Institution SSO Error: multiple SSO email [sso_email={sso_email}, sso_identity={sso_identity}, institution={institution._id}]'
250+
sentry.log_message(message)
251+
logger.error(message)
252+
raise PermissionDenied(detail='InstitutionSsoMultipleEmailsNotSupported')
238253
# Attempt to find an existing user that matches the email(s) provided via SSO. Create a new one if not found.
239254
# If a user is found, it is possible that the user is inactive (e.g. unclaimed, disabled, unconfirmed, etc.).
240255
# If a new user is created, the user object is confirmed but not registered (i.e. inactive until registered).

api_tests/institutions/views/test_institution_auth.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,135 @@ def test_user_external_unconfirmed(self, app, institution, url_auth_institution)
494494
assert accepted_terms_of_service == user.accepted_terms_of_service
495495
assert not user.has_usable_password()
496496

497+
def test_duplicate_emails_and_names_success_existing_user(self, app, institution, url_auth_institution):
498+
username, fullname, password = 'user_deanseu@user.edu', 'Foo Bar', 'FuAsKeEr'
499+
exiting_user = make_user(username, fullname)
500+
exiting_user.set_password(password)
501+
exiting_user.save()
502+
sso_email = f'{username};{username}'
503+
with capture_signals() as mock_signals:
504+
res = app.post(
505+
url_auth_institution,
506+
make_payload(
507+
institution,
508+
sso_email,
509+
family_name='User;User',
510+
given_name='Fake;Fake',
511+
fullname='Fake User;Fake User',
512+
)
513+
)
514+
assert res.status_code == 204
515+
assert not mock_signals.signals_sent()
516+
user = OSFUser.objects.filter(username=username).first()
517+
assert user
518+
assert user.fullname == fullname
519+
affiliation = user.get_institution_affiliation(institution._id)
520+
assert affiliation.sso_mail == username
521+
assert user.has_usable_password()
522+
assert user.check_password(password)
523+
assert institution in user.get_affiliated_institutions()
524+
525+
def test_duplicate_emails_and_names_success_new_user(self, app, institution, url_auth_institution):
526+
username, fullname, family_name, given_name = 'user_deansnu@user.edu', 'Fake User', 'User', 'Fake'
527+
sso_email = f'{username};{username}'
528+
with capture_signals() as mock_signals:
529+
res = app.post(
530+
url_auth_institution,
531+
make_payload(
532+
institution,
533+
sso_email,
534+
family_name=f'{family_name};{family_name}',
535+
given_name=f'{given_name};{given_name}',
536+
fullname=f'{fullname};{fullname}',
537+
)
538+
)
539+
assert res.status_code == 204
540+
assert mock_signals.signals_sent()
541+
user = OSFUser.objects.filter(username=username).first()
542+
assert user
543+
assert user.fullname == fullname
544+
assert user.family_name == family_name
545+
assert user.given_name == given_name
546+
affiliation = user.get_institution_affiliation(institution._id)
547+
assert affiliation.sso_mail == username
548+
assert user.has_usable_password()
549+
assert institution in user.get_affiliated_institutions()
550+
551+
def test_multiple_names_warning_exiting_user(self, app, institution, url_auth_institution):
552+
username, fullname, password = 'user_mnweu@user.edu', 'Foo Bar', 'FuAsKeEr'
553+
exiting_user = make_user(username, fullname)
554+
exiting_user.set_password(password)
555+
exiting_user.save()
556+
with capture_signals() as mock_signals:
557+
res = app.post(
558+
url_auth_institution,
559+
make_payload(
560+
institution,
561+
username,
562+
family_name='User1;User2',
563+
given_name='Fake1;Fake2',
564+
fullname='Fake1 User1;Fake2 User2',
565+
)
566+
)
567+
assert res.status_code == 204
568+
assert not mock_signals.signals_sent()
569+
user = OSFUser.objects.filter(username=username).first()
570+
assert user
571+
assert user.fullname == fullname
572+
affiliation = user.get_institution_affiliation(institution._id)
573+
assert affiliation.sso_mail == username
574+
assert user.has_usable_password()
575+
assert user.check_password(password)
576+
assert institution in user.get_affiliated_institutions()
577+
578+
def test_multiple_names_warning_new_user(self, app, institution, url_auth_institution):
579+
sso_email, fullname, family_name, given_name = 'user_deansnu@user.edu', 'Fake User;Foo Bar', 'User;Bar', 'Fake;Foo'
580+
with capture_signals() as mock_signals:
581+
res = app.post(
582+
url_auth_institution,
583+
make_payload(institution, sso_email, family_name=family_name, given_name=given_name, fullname=fullname),
584+
)
585+
assert res.status_code == 204
586+
assert mock_signals.signals_sent()
587+
user = OSFUser.objects.filter(username=sso_email).first()
588+
assert user
589+
assert user.fullname == fullname
590+
assert user.family_name == family_name
591+
assert user.given_name == given_name
592+
affiliation = user.get_institution_affiliation(institution._id)
593+
assert affiliation.sso_mail == sso_email
594+
assert user.has_usable_password()
595+
assert institution in user.get_affiliated_institutions()
596+
597+
def test_multiple_emails_failure_existing_user(self, app, institution, url_auth_institution):
598+
username, second_email, fullname, password = 'user_mefeu_a', 'user_mefeu_b@user.edu', 'Fake User', 'FuAsKeEr'
599+
existing_uesr = make_user(username, fullname)
600+
existing_uesr.set_password(password)
601+
existing_uesr.save()
602+
sso_email = f'{username};{second_email}'
603+
with capture_signals() as mock_signals:
604+
res = app.post(
605+
url_auth_institution,
606+
make_payload(institution, sso_email=sso_email, fullname=fullname),
607+
expect_errors=True,
608+
)
609+
assert res.status_code == 403
610+
assert res.json['errors'][0]['detail'] == 'InstitutionSsoMultipleEmailsNotSupported'
611+
assert not mock_signals.signals_sent()
612+
613+
def test_multiple_emails_failure_new_user(self, app, institution, url_auth_institution):
614+
first_email, second_email, family_name, given_name = 'user_mefeu_a', 'user_mefeu_b@user.edu', 'User', 'Fake'
615+
sso_email = f'{first_email};{second_email}'
616+
with capture_signals() as mock_signals:
617+
res = app.post(
618+
url_auth_institution,
619+
make_payload(institution, sso_email, family_name=family_name, given_name=given_name),
620+
expect_errors=True,
621+
)
622+
assert res.status_code == 403
623+
assert res.json['errors'][0]['detail'] == 'InstitutionSsoMultipleEmailsNotSupported'
624+
assert not mock_signals.signals_sent()
625+
497626

498627
@pytest.mark.django_db
499628
class TestInstitutionStorageRegion:

framework/auth/__init__.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
from django.utils import timezone
44

5-
from framework import bcrypt
5+
from framework import bcrypt, sentry
66
from framework.auth import signals
77
from framework.auth.core import Auth
88
from framework.auth.core import get_user, generate_verification_key
9-
from framework.auth.exceptions import DuplicateEmailError
9+
from framework.auth.exceptions import DuplicateEmailError, MultipleSSOEmailError
1010
from framework.auth.tasks import update_user_from_activity
1111
from framework.auth.utils import LogLevel, print_cas_log
1212
from framework.celery_tasks.handlers import enqueue_task
@@ -159,6 +159,19 @@ def get_or_create_institutional_user(fullname, sso_email, sso_identity, primary_
159159
return user, True, None, None, sso_identity
160160

161161

162+
def deduplicate_sso_attributes(attr_name, attr_value, delimiter=';'):
163+
if delimiter not in attr_value:
164+
return attr_value
165+
value_set = set(attr_value.split(delimiter))
166+
if len(value_set) != 1:
167+
message = f'Multiple values found for SSO attribute: [{attr_name}={attr_value}]'
168+
sentry.log_message(message)
169+
if attr_name == 'sso_email':
170+
raise MultipleSSOEmailError(message)
171+
return attr_value
172+
return value_set.pop()
173+
174+
162175
def get_or_create_user(fullname, address, reset_password=True, is_spam=False):
163176
"""
164177
Get or create user by fullname and email address.

framework/auth/exceptions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,8 @@ class MergeConflictError(EmailConfirmTokenError):
6565
"""Raised if a merge is not possible due to a conflict"""
6666
message_short = language.CANNOT_MERGE_ACCOUNTS_SHORT
6767
message_long = language.CANNOT_MERGE_ACCOUNTS_LONG
68+
69+
70+
class MultipleSSOEmailError(AuthError):
71+
"""Raised if institution SSO provides multiple emails which OSF cannot deduplicate."""
72+
pass

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "OSF",
3-
"version": "25.15.1",
3+
"version": "25.15.2",
44
"description": "Facilitating Open Science",
55
"repository": "https://github.com/CenterForOpenScience/osf.io",
66
"author": "Center for Open Science",

0 commit comments

Comments
 (0)