def test_run_hook_nothing_to_import(): """Test run_hook function by passing function name only (no path) and checking if it raises.""" with pytest.raises(SAMLAuthError) as exc_info: run_hook("divide") assert str(exc_info.value ) == "There's nothing to import. Check your hook's import path!"
def test_run_hook_no_function_path(): """Test run_hook function by passing invalid function path and checking if it raises.""" with pytest.raises(SAMLAuthError) as exc_info: run_hook("") run_hook(None) assert str(exc_info.value) == "function_path isn't specified"
def test_run_hook_division_by_zero(): """Test function imported by run_hook to verify if run_hook correctly captures the exception.""" with pytest.raises(SAMLAuthError) as exc_info: run_hook("django_saml2_auth.tests.test_utils.divide", 2, b=0) assert str(exc_info.value) == "division by zero" # Actually a ZeroDivisionError wrapped in SAMLAuthError assert isinstance(exc_info.value.extra["exc"], ZeroDivisionError) assert exc_info.value.extra["exc_type"] == ZeroDivisionError
def test_run_hook_import_error(): """Test run_hook function by passing correct path, but nonexistent function and checking if it raises.""" with pytest.raises(SAMLAuthError) as exc_info: run_hook("django_saml2_auth.tests.test_utils.nonexistent_divide", 2, b=2) assert str(exc_info.value) == ( "module 'django_saml2_auth.tests.test_utils' has no attribute 'nonexistent_divide'" ) assert isinstance(exc_info.value.extra["exc"], AttributeError) assert exc_info.value.extra["exc_type"] == AttributeError
def get_metadata(user_id: Optional[str] = None) -> Mapping[str, Any]: """Returns metadata information, either by running the GET_METADATA_AUTO_CONF_URLS hook function if available, or by checking and returning a local file path or the METADATA_AUTO_CONF_URL. URLs are always validated and invalid URLs will be either filtered or raise a SAMLAuthError exception. Args: user_id (str, optional): If passed, it will be further processed by the GET_METADATA_AUTO_CONF_URLS trigger, which will return the metadata URL corresponding to the given user identifier, either email or username. Defaults to None. Raises: SAMLAuthError: No metadata URL associated with the given user identifier. SAMLAuthError: Invalid metadata URL. Returns: Mapping[str, Any]: Returns a SAML metadata object as dictionary """ get_metadata_trigger = dictor(settings.SAML2_AUTH, "TRIGGER.GET_METADATA_AUTO_CONF_URLS") if get_metadata_trigger: metadata_urls = run_hook(get_metadata_trigger, user_id) if metadata_urls: # Filter invalid metadata URLs filtered_metadata_urls = list( filter(lambda md: validate_metadata_url(md["url"]), metadata_urls)) return {"remote": filtered_metadata_urls} else: raise SAMLAuthError( "No metadata URL associated with the given user identifier.", extra={ "exc_type": ValueError, "error_code": NO_METADATA_URL_ASSOCIATED, "reason": "There was an error processing your request.", "status_code": 500 }) metadata_local_file_path = settings.SAML2_AUTH.get( "METADATA_LOCAL_FILE_PATH") if metadata_local_file_path: return {"local": [metadata_local_file_path]} else: single_metadata_url = settings.SAML2_AUTH.get("METADATA_AUTO_CONF_URL") if validate_metadata_url(single_metadata_url): return {"remote": [{"url": single_metadata_url}]} else: raise SAMLAuthError( "Invalid metadata URL.", extra={ "exc_type": ValueError, "error_code": INVALID_METADATA_URL, "reason": "There was an error processing your request.", "status_code": 500 })
def test_run_hook_success(): """Test run_hook function against divide function imported from current module.""" result = run_hook("django_saml2_auth.tests.test_utils.divide", 2, b=2) assert result == 1
def acs(request: HttpRequest): """Assertion Consumer Service is SAML terminology for the location at a ServiceProvider that accepts <samlp:Response> messages (or SAML artifacts) for the purpose of establishing a session based on an assertion. Assertion is a signed authentication request from identity provider (IdP) to acs endpoint. Args: request (HttpRequest): Incoming request from identity provider (IdP) for authentication Exceptions: SAMLAuthError: The target user is inactive. Returns: HttpResponseRedirect: Redirect to various endpoints: denied, welcome or next_url (e.g. the front-end app) Notes: https://wiki.shibboleth.net/confluence/display/CONCEPT/AssertionConsumerService """ authn_response = decode_saml_response(request, acs) user = extract_user_identity(authn_response.get_identity()) next_url = request.session.get("login_next_url") or get_default_next_url() # If RelayState params is passed, it is a JWT token that identifies the user trying to login # via sp_initiated_login endpoint relay_state = request.POST.get("RelayState") if relay_state: redirected_user_id = decode_jwt_token(relay_state) # This prevents users from entering an email on the SP, but use a different email on IdP if get_user_id(user) != redirected_user_id: raise SAMLAuthError("The user identifier doesn't match.", extra={ "exc_type": ValueError, "error_code": USER_MISMATCH, "reason": "User identifier mismatch.", "status_code": 403 }) is_new_user, target_user = get_or_create_user(user) before_login_trigger = dictor(settings.SAML2_AUTH, "TRIGGER.BEFORE_LOGIN") if before_login_trigger: run_hook(before_login_trigger, user) request.session.flush() use_jwt = settings.SAML2_AUTH.get("USE_JWT", False) if use_jwt and target_user.is_active: # Create a new JWT token for IdP-initiated login (acs) jwt_token = create_jwt_token(target_user.email) # Use JWT auth to send token to frontend query = f"?token={jwt_token}" frontend_url = settings.SAML2_AUTH.get("FRONTEND_URL", next_url) return HttpResponseRedirect(frontend_url + query) if target_user.is_active: model_backend = "django.contrib.auth.backends.ModelBackend" login(request, target_user, model_backend) after_login_trigger = dictor(settings.SAML2_AUTH, "TRIGGER.AFTER_LOGIN") if after_login_trigger: run_hook(after_login_trigger, request.session, user) else: raise SAMLAuthError("The target user is inactive.", extra={ "exc_type": Exception, "error_code": INACTIVE_USER, "reason": "User is inactive.", "status_code": 500 }) if is_new_user: try: return render(request, "django_saml2_auth/welcome.html", {"user": request.user}) except TemplateDoesNotExist: return HttpResponseRedirect(next_url) else: return HttpResponseRedirect(next_url)
def get_or_create_user(user: Dict[str, Any]) -> Tuple[bool, Type[Model]]: """Get or create a new user and optionally add it to one or more group(s) Args: user (Dict[str, Any]): User information Raises: SAMLAuthError: Cannot create user. Returns: Tuple[bool, Type[Model]]: A tuple containing user creation status and user object """ user_model = get_user_model() created = False try: target_user = get_user(user) except user_model.DoesNotExist: should_create_new_user = settings.SAML2_AUTH.get("CREATE_USER", True) if should_create_new_user: target_user = create_new_user(get_user_id(user), user["first_name"], user["last_name"]) create_user_trigger = dictor(settings.SAML2_AUTH, "TRIGGER.CREATE_USER") if create_user_trigger: run_hook(create_user_trigger, user) target_user.refresh_from_db() created = True else: raise SAMLAuthError( "Cannot create user.", extra={ "exc_type": Exception, "error_code": SHOULD_NOT_CREATE_USER, "reason": "Due to current config, a new user should not be created.", "status_code": 500 }) # Optionally update this user's group assignments by updating group memberships from SAML groups # to Django equivalents group_attribute = dictor(settings.SAML2_AUTH, "ATTRIBUTES_MAP.groups") group_map = settings.SAML2_AUTH.get("GROUPS_MAP") if group_attribute and group_attribute in user["user_identity"]: groups = [] for group_name in user["user_identity"][group_attribute]: # Group names can optionally be mapped to different names in Django if group_map and group_name in group_map: group_name_django = group_map[group_name] else: group_name_django = group_name try: groups.append(Group.objects.get(name=group_name_django)) except Group.DoesNotExist: pass if parse_version(get_version()) >= parse_version("2.0"): target_user.groups.set(groups) else: target_user.groups = groups return (created, target_user)