Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
224f38b
allow angular url to be in next_url
ihorsokhanexoft Feb 11, 2026
7b67b12
renamed constant
ihorsokhanexoft Feb 12, 2026
235dac6
added tests and debug mode
ihorsokhanexoft Feb 13, 2026
23dd926
angular url
ihorsokhanexoft Feb 11, 2026
cb141e1
angular url
ihorsokhanexoft Feb 12, 2026
b16005c
added tests and debug mode
ihorsokhanexoft Feb 13, 2026
d48f9ae
edited comments
ihorsokhanexoft Feb 16, 2026
261a680
added sso_in_progress field to Institution model
ihorsokhanexoft Feb 16, 2026
7c7dab7
Merge pull request #11580 from ihorsokhanexoft/fix/ENG-10238
Ostap-Zherebetskyi Feb 18, 2026
e966ae1
Merge pull request #11592 from ihorsokhanexoft/feature/ENG-10289
Ostap-Zherebetskyi Feb 18, 2026
5d474ef
Add tests
Vlad0n20 Feb 13, 2026
2fce58d
Merge pull request #11589 from Vlad0n20/fix/ENG-10272
Ostap-Zherebetskyi Feb 19, 2026
7a54304
Merge remote-tracking branch 'upstream/develop' into feature/osf4i-in…
cslzchen Mar 19, 2026
179bed0
Add SSO availability field and update institution reactivation logic
Ostap-Zherebetskyi Mar 19, 2026
1d74ad3
Update SSO availability logic and add tests for institution deactivation
Ostap-Zherebetskyi Mar 19, 2026
26efc85
Apply suggestions from @cslzchen
cslzchen Mar 19, 2026
1c709f6
Merge pull request #11629 from Ostap-Zherebetskyi/feature/reactivate_…
cslzchen Mar 19, 2026
73dfa28
Add CAS login URL property and implement copy modal in institution de…
Ostap-Zherebetskyi Mar 20, 2026
ff32e01
Refactor SSO URL generation and clean up unused code in institution m…
Ostap-Zherebetskyi Mar 23, 2026
e67647a
Merge pull request #11645 from Ostap-Zherebetskyi/feature/sso_login_url
cslzchen Mar 23, 2026
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
1 change: 1 addition & 0 deletions admin/institutions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def get_context_data(self, *args, **kwargs):
institution_dict = model_to_dict(institution)
kwargs.setdefault('page_number', self.request.GET.get('page', '1'))
kwargs['institution'] = institution_dict
kwargs['cas_login_url'] = institution.cas_login_url
kwargs['logo_path'] = institution.logo_path
kwargs['banner_path'] = institution.banner_path
fields = institution_dict
Expand Down
62 changes: 61 additions & 1 deletion admin/templates/institutions/detail.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,33 @@
{% extends "base.html" %}
{% load static %}
{% block top_includes %}
<link rel="stylesheet" type="text/css" href="/static/css/institutions.css" />
<link rel="stylesheet" type="text/css" href="/static/css/institutions.css" />
<style>
#copy-modal {
display: none; /* hidden by default */
position: fixed;
z-index: 2000;
inset: 0;
}
#copy-modal.show_modal {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
#copy-modal .modal-content {
background: white;
width: 100%;
max-width: 600px;
max-height: 80vh;
padding: 20px;
border-radius: 10px;
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
}
</style>
{% endblock %}
{% load comment_extras %}
{% block title %}
Expand Down Expand Up @@ -33,6 +59,18 @@
{% if perms.osf.change_institution %}
<a class="btn btn-primary" href={% url 'institutions:list_and_add_admin' institution.id %}>Manage Admins</a>
{% endif %}
{% if cas_login_url %}
<button class="btn btn-primary" onclick="openCopyPopup('{{ cas_login_url|escapejs }}')">
Copy SSO URL
</button>
<div id="copy-modal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeCopyPopup()">&times;</span>
<p>Value copied. You can also copy manually:</p>
<textarea id="copy-input" readonly></textarea>
</div>
</div>
{% endif %}
</div>
</div>

Expand Down Expand Up @@ -168,5 +206,27 @@ <h3>Are you sure you want to run monthly report for this institution?</h3>
});
});
});

window.openCopyPopup = function(text) {
const modal = document.getElementById("copy-modal");
const input = document.getElementById("copy-input");
input.value = text;
modal.classList.add("show_modal");
navigator.clipboard.writeText(text).catch(() => {});
input.focus();
input.select();
};

window.closeCopyPopup = function() {
document.getElementById("copy-modal").classList.remove("show_modal");
};

// Close on outside click
window.onclick = function(event) {
const modal = document.getElementById("copy-modal");
if (event.target === modal) {
modal.classList.remove("show_modal");
}
};
</script>
{% endblock %}
6 changes: 4 additions & 2 deletions admin_tests/institutions/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@ def test_institution_form(self):
'name': 'New Name',
'logo_name': 'awesome_logo.png',
'domains': 'http://kris.biz/, http://www.little.biz/',
'_id': 'newawesomeprov'
'_id': 'newawesomeprov',
'sso_availability': 'Public',
}
form = InstitutionForm(data=new_data)
assert form.is_valid()
Expand Down Expand Up @@ -214,7 +215,8 @@ def test_monthly_reporter_called_on_create(self, mock_monthly_reporter_do):
'email_domains': FakeList('domain_name', n=1),
'orcid_record_verified_source': '',
'delegation_protocol': '',
'institutional_request_access_enabled': False
'institutional_request_access_enabled': False,
'sso_availability': 'Public',
}
form = InstitutionForm(data=data)
assert form.is_valid()
Expand Down
4 changes: 4 additions & 0 deletions framework/auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1203,6 +1203,10 @@ def validate_next_url(next_url):
:return: True if valid, False otherwise
"""

# allow redirection to angular locally
if settings.LOCAL_ANGULAR_URL in next_url and settings.DEBUG_MODE:
return True

# disable external domain using `//`: the browser allows `//` as a shortcut for non-protocol specific requests
# like http:// or https:// depending on the use of SSL on the page already.
if next_url.startswith('//'):
Expand Down
18 changes: 18 additions & 0 deletions osf/migrations/0038_institution_sso_availability.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.15 on 2026-03-13 11:11

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('osf', '0037_notification_refactor_post_release'),
]

operations = [
migrations.AddField(
model_name='institution',
name='sso_availability',
field=models.CharField(choices=[('Public', 'PUBLIC'), ('Unavailable', 'UNAVAILABLE'), ('Hidden', 'HIDDEN')], default='Hidden', max_length=15),
),
]
35 changes: 35 additions & 0 deletions osf/models/institution.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from .validators import validate_email
from osf.utils.fields import NonNaiveDateTimeField, LowercaseEmailField
from website import settings as website_settings
from urllib.parse import quote

logger = logging.getLogger(__name__)

Expand All @@ -46,6 +47,13 @@ class SsoFilterCriteriaAction(Enum):
CONTAINS = 'contains' # Type 2: SSO releases a multi-value attribute, of which one value matches
IN = 'in' # Type 3: SSO releases a single-value attribute that have multiple valid values

class SSOAvailability(Enum):
"""Defines 3 SSO availability states for institutions.
"""
PUBLIC = 'Public' # Active, has a delegation protocol and SSO setup has been verified
UNAVAILABLE = 'Unavailable' # Does not have a delegation protocol
HIDDEN = 'Hidden' # 1) Inactive and has a delegation protocol, or 2) active, has a delegation protocol and SSO setup is in-progress


class InstitutionManager(models.Manager):

Expand Down Expand Up @@ -79,6 +87,13 @@ class Institution(DirtyFieldsMixin, Loggable, ObjectIDMixin, BaseModel, Guardian
default=''
)

# Institution SSO availability
sso_availability = models.CharField(
choices=[(choice.value, choice.name) for choice in SSOAvailability],
max_length=15,
default=SSOAvailability.HIDDEN.value
)

# Default Storage Region
storage_regions = models.ManyToManyField(
'addons_osfstorage.Region',
Expand Down Expand Up @@ -194,6 +209,17 @@ def banner_path(self):
except InstitutionAssetFile.DoesNotExist:
return '/static/img/institutions/banners/placeholder-banner.png'

@property
def cas_login_url(self):
if self.delegation_protocol == IntegrationType.NONE.value:
return None
if 'localhost' in website_settings.DOMAIN:
next_param = quote(website_settings.PROTOCOL + website_settings.LOCAL_ANGULAR_URL, safe='')
else:
next_param = quote(website_settings.DOMAIN, safe='')
service_url = quote(f'{website_settings.DOMAIN}login?next={next_param}', safe='')
return f'{website_settings.CAS_SERVER_URL}/login?campaign=institution&institutionId={self._id}&service={service_url}'

def update_search(self):
from website.search.search import update_institution
from website.search.exceptions import SearchUnavailableError
Expand Down Expand Up @@ -237,6 +263,11 @@ def deactivate(self):
"""
if not self.deactivated:
self.deactivated = timezone.now()
if not self.delegation_protocol:
self.sso_availability = SSOAvailability.UNAVAILABLE.value
else:
self.sso_availability = SSOAvailability.HIDDEN.value

self.save()
# Django mangers aren't used when querying on related models. Thus, we can query
# affiliated users and send notification emails after the institution has been deactivated.
Expand All @@ -251,6 +282,10 @@ def reactivate(self):
"""
if self.deactivated:
self.deactivated = None
if not self.delegation_protocol:
self.sso_availability = SSOAvailability.UNAVAILABLE.value
else:
self.sso_availability = SSOAvailability.HIDDEN.value
self.save()
else:
message = f'Action rejected - reactivating an active institution [{self._id}].'
Expand Down
25 changes: 25 additions & 0 deletions osf_tests/test_institution.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,20 @@ def test_deactivated_institution_in_all_institutions(self):
institution.save()
assert institution in Institution.objects.get_all_institutions()

def test_deactivate_sso_institution(self):
institution = InstitutionFactory()
institution.delegation_protocol = 'saml-shib'
institution.save()
with mock.patch.object(
institution,
'_send_deactivation_email',
return_value=None
) as mock__send_deactivation_email:
institution.deactivate()
assert institution.deactivated is not None
assert mock__send_deactivation_email.called
assert institution.sso_availability == 'Hidden'

def test_deactivate_institution(self):
institution = InstitutionFactory()
with mock.patch.object(
Expand All @@ -138,13 +152,24 @@ def test_deactivate_institution(self):
institution.deactivate()
assert institution.deactivated is not None
assert mock__send_deactivation_email.called
assert institution.sso_availability == 'Unavailable'

def test_reactivate_sso_institution(self):
institution = InstitutionFactory()
institution.delegation_protocol = 'saml-shib'
institution.deactivated = timezone.now()
institution.save()
institution.reactivate()
assert institution.deactivated is None
assert institution.sso_availability == 'Hidden'

def test_reactivate_institution(self):
institution = InstitutionFactory()
institution.deactivated = timezone.now()
institution.save()
institution.reactivate()
assert institution.deactivated is None
assert institution.sso_availability == 'Unavailable'

def test_send_deactivation_email_call_count(self):
institution = InstitutionFactory()
Expand Down
27 changes: 27 additions & 0 deletions tests/test_auth_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,17 @@ def test_next_url_login_with_auth(self):
assert data.get('status_code') == http_status.HTTP_302_FOUND
assert data.get('next_url') == self.next_url

def test_next_url_angular_login_with_auth(self):
data = login_and_register_handler(self.auth, next_url=settings.LOCAL_ANGULAR_URL)
assert data.get('status_code') == http_status.HTTP_302_FOUND
assert data.get('next_url') == settings.LOCAL_ANGULAR_URL

def test_next_url_angular_login_without_auth(self):
request.url = web_url_for('auth_login', next=settings.LOCAL_ANGULAR_URL, _absolute=True)
data = login_and_register_handler(self.no_auth, next_url=settings.LOCAL_ANGULAR_URL)
assert data.get('status_code') == http_status.HTTP_302_FOUND
assert data.get('next_url') == cas.get_login_url(request.url)

def test_next_url_login_without_auth(self):
# login: user without auth
request.url = web_url_for('auth_login', next=self.next_url, _absolute=True)
Expand Down Expand Up @@ -827,6 +838,22 @@ def test_logout_with_no_parameter(self):
assert resp.status_code == http_status.HTTP_302_FOUND
assert cas.get_logout_url(self.goodbye_url) == resp.headers['Location']

@mock.patch('framework.auth.views.settings.LOCAL_ANGULAR_URL', 'http://localhost:4200')
def test_logout_with_angular_next_url_logged_in(self):
angular_url = 'http://localhost:4200/'
logout_url = web_url_for('auth_logout', _absolute=True, next=angular_url)
resp = self.app.get(logout_url, auth=self.auth_user.auth)
assert resp.status_code == http_status.HTTP_302_FOUND
assert cas.get_logout_url(logout_url) == resp.headers['Location']

@mock.patch('framework.auth.views.settings.LOCAL_ANGULAR_URL', 'http://localhost:4200')
def test_logout_with_angular_next_url_logged_out(self):
angular_url = 'http://localhost:4200/'
logout_url = web_url_for('auth_logout', _absolute=True, next=angular_url)
resp = self.app.get(logout_url, auth=None)
assert resp.status_code == http_status.HTTP_302_FOUND
assert angular_url == resp.headers['Location']


class TestResetPassword(OsfTestCase):

Expand Down
1 change: 1 addition & 0 deletions website/settings/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ def parent_dir(path):
INTERNAL_DOMAIN = DOMAIN
API_DOMAIN = PROTOCOL + 'localhost:8000/'
RESET_PASSWORD_URL = PROTOCOL + 'localhost:5000/resetpassword/' # TODO set angular reset password url
LOCAL_ANGULAR_URL = 'localhost:4200'

PREPRINT_PROVIDER_DOMAINS = {
'enabled': False,
Expand Down
Loading