Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d8454bb
CommunicationProviderABC.is_enabled raises ServerCommunicationError i…
byewokko Mar 12, 2026
9150245
Always show password reset link if SMTP is not configured
byewokko Mar 12, 2026
2ac59bf
Handle ServerCommunicationError in smslogin
byewokko Mar 13, 2026
4d028b6
update docstring
byewokko Mar 13, 2026
bc3b86a
handle ServerCommunicationError in invitation
byewokko Mar 13, 2026
ed059d4
handle ServerCommunicationError in invitation
byewokko Mar 13, 2026
285cd8c
fix disclosure condition and make it clearer
byewokko Mar 17, 2026
4a7ccc8
do not suppress server errors when checking for mfa availability
byewokko Mar 17, 2026
6cacfd1
Merge branch 'main' into feature/always-show-link-if-smtp-not-configured
byewokko Mar 17, 2026
3b5ff59
do not suppress server errors when checking for mfa availability
byewokko Mar 17, 2026
dee30a2
improve error messages
byewokko Mar 18, 2026
c0c6edd
remove resource requirement because endpoint has no tenant context
byewokko Mar 26, 2026
5cd564c
add successful email_sent result
byewokko Mar 26, 2026
6c7eb60
add error codes
byewokko Mar 26, 2026
a3f6544
cleared error message
byewokko Mar 26, 2026
5fa0985
refactor email handling to remove duplicatiob
byewokko Mar 27, 2026
c51100d
unify email handling in resend invitation with create invitation
byewokko Mar 27, 2026
a019f94
Merge branch 'main' into feature/always-show-link-if-smtp-not-configured
byewokko Apr 1, 2026
a592d2f
update changelog
byewokko Apr 7, 2026
4987729
improve error handling and responses
byewokko Apr 9, 2026
cedd909
add docstring
byewokko Apr 9, 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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# CHANGELOG

## v26.15

### Pre-releases
- v26.15-alpha

### Features
- Always include link in response if SMTP is not configured (#561, v26.15-alpha)

---


## v25.48

### Pre-releases
Expand Down
13 changes: 9 additions & 4 deletions seacatauth/authn/login_factors/smscode.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import logging

from .abc import LoginFactorABC
from ...generic import generate_ergonomic_token
from ... import exceptions
from .abc import LoginFactorABC


L = logging.getLogger(__name__)
Expand All @@ -11,9 +12,13 @@ class SMSCodeFactor(LoginFactorABC):
Type = "smscode"

async def is_eligible(self, login_data) -> bool:
if not await self.AuthenticationService.CommunicationService.is_channel_enabled("sms"):
# SMS provider is not configured
return False
try:
if not await self.AuthenticationService.CommunicationService.is_channel_enabled("sms"):
# SMS provider is not configured
return False
except exceptions.ServerCommunicationError as e:
L.error("Unable to determine if SMS code factor is enabled: {}".format(e))
raise e

cred_svc = self.AuthenticationService.CredentialsService
cred_id = login_data["credentials_id"]
Expand Down
9 changes: 9 additions & 0 deletions seacatauth/communication/providers/abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ async def can_send_to_target(self, credentials: dict) -> bool:


async def is_enabled(self) -> bool:
"""
Check if the provider is enabled and can be used to send messages.

Returns:
True if the provider is enabled, False otherwise.

Raises:
exceptions.ServerCommunicationError: If the result cannot be determined due to an error communicating with the external service.
"""
raise NotImplementedError()


Expand Down
15 changes: 9 additions & 6 deletions seacatauth/communication/providers/email_iris.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,17 @@ async def is_enabled(self) -> bool:
async with session.get("features") as resp:
response = await resp.json()
if resp.status != 200:
L.error("Error response from ASAB Iris: {}".format(response))
return False
message = "Error response from ASAB Iris"
L.error("{}: {}".format(message, response))
raise exceptions.ServerCommunicationError(message)
except aiohttp.ClientError as e:
L.error("Error connecting to ASAB Iris: {}".format(e))
return False
message = "Error connecting to ASAB Iris"
L.error("{}: {}".format(message, e))
raise exceptions.ServerCommunicationError(message) from e
except asyncio.TimeoutError:
L.error("Error connecting to ASAB Iris: Connection timed out")
return False
message = "Connection to ASAB Iris timed out"
L.error(message)
raise exceptions.ServerCommunicationError(message) from None

enabled_orchestrators = response.get("orchestrators", [])
return "email" in enabled_orchestrators
Expand Down
25 changes: 25 additions & 0 deletions seacatauth/communication/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ async def can_send_to_target(self, credentials: dict, channel: str) -> bool:


async def is_channel_enabled(self, channel) -> bool:
"""
Check if the specified communication channel is enabled and can be used to send messages.

Returns:
True if the channel is enabled, False otherwise.

Raises:
exceptions.ServerCommunicationError: If there was an error communicating with the external service.
"""
if channel not in self.CommunicationProviders:
return False
return await self.CommunicationProviders[channel].is_enabled()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -128,6 +137,22 @@ async def build_and_send_message(
locale: str = None,
**kwargs
):
"""
Builds a message from the specified template and sends it via the specified channel.

Args:
credentials: The credentials dict containing the necessary information to determine the message recipient.
template_id: The ID of the template to use for building the message.
channel: The communication channel to use for sending the message.
locale: The locale to use for selecting the template. If not specified, the default locale will be used.
**kwargs: Additional keyword arguments to pass to the template for rendering.

Raises:
exceptions.CommunicationNotConfiguredError: If the communication service is not properly configured.
exceptions.CommunicationChannelNotAvailableError: If the specified channel is not available for the given credentials.
exceptions.MessageDeliveryError: If there was an error delivering the message.
exceptions.ServerCommunicationError: If there was an error communicating with the external service.
"""
if not self.is_enabled():
raise exceptions.CommunicationNotConfiguredError()

Expand Down
38 changes: 24 additions & 14 deletions seacatauth/credentials/change_password/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,14 @@ async def reset_password(self, request, *, json_data):
@asab.web.auth.require(ResourceId.CREDENTIALS_EDIT)
async def admin_request_password_reset(self, request, *, json_data):
"""
Send a password reset link to specified user and return it in the response if the caller is superuser.
As long as either email communication is successful or the caller is superuser, the result is
considered successful.
Send a password reset link to the specified user and return it in the response if the caller is superuser or
if SMTP service is not enabled. The operation is considered successful if either:
- The email with the reset link is successfully sent to the user, or
- The link is disclosed in the response.

If the primary action (reset link creation) fails, the response will indicate failure regardless of email status.
If the reset link is created but cannot be delivered (neither emailed nor disclosed), the response will indicate failure.
The response includes multi-status details for both link creation and email delivery, as applicable.
"""
response_data = {}
authz = asab.contextvars.Authz.get()
Expand All @@ -226,10 +231,15 @@ async def admin_request_password_reset(self, request, *, json_data):
# Check if password reset link can be disclosed (in email or at least in the response)
password_reset_url = None
url_disclosed = False
can_get_link_in_response = authz.has_superuser_access()
email_service_enabled = await self.ChangePasswordService.CommunicationService.is_channel_enabled("email")
can_email_to_target = await self.ChangePasswordService.CommunicationService.can_send_to_target(
credentials, "email")
email_service_enabled: bool | None = None # None if status cannot be determined due to error
credentials_have_email: bool = credentials.get("email") not in (None, "")
try:
email_service_enabled = await self.ChangePasswordService.CommunicationService.is_channel_enabled("email")
except exceptions.ServerCommunicationError:
L.log(asab.LOG_NOTICE, "Cannot check email service availability: Communication error.", struct_data={
"cid": credentials_id})

can_get_link_in_response: bool = authz.has_superuser_access() or email_service_enabled is False

# Superusers receive the password reset link in response
if can_get_link_in_response:
Expand All @@ -246,10 +256,10 @@ async def admin_request_password_reset(self, request, *, json_data):
"cid": credentials_id})
email_delivery_result = {
"result": "ERROR",
"tech_err": "Email service not available.",
"error": "SeaCatAuthError|Email service not available",
"tech_err": "Email service is not enabled.",
"error": "SeaCatAuthError|Email service is not enabled",
}
elif not can_email_to_target:
elif not credentials_have_email:
L.error("Cannot send password reset email: Credentials have no email address.", struct_data={
"cid": credentials_id})
email_delivery_result = {
Expand Down Expand Up @@ -277,16 +287,16 @@ async def admin_request_password_reset(self, request, *, json_data):
"cid": credentials_id})
email_delivery_result = {
"result": "ERROR",
"tech_err": "Cannot connect to the email service.",
"error": "SeaCatAuthError|Cannot connect to the email service",
"tech_err": "Email service is temporarily unavailable.",
"error": "SeaCatAuthError|Email service is temporarily unavailable",
}
except exceptions.MessageDeliveryError as e:
L.error("Cannot send password reset email: {}".format(e), struct_data={
"cid": credentials_id})
email_delivery_result = {
"result": "ERROR",
"tech_err": "Failed to send password reset link.",
"error": "SeaCatAuthError|Failed to send password reset link",
"tech_err": "Email delivery error.",
"error": "SeaCatAuthError|Email delivery error",
}

response_data["email_sent"] = email_delivery_result
Expand Down
Loading
Loading