Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 13 additions & 7 deletions auth/cognito/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ def _bare_domain(self) -> str:
"""Domain without protocol prefix for URL construction."""
d = (self.domain or "").strip().rstrip("/")
if d.startswith("https://"):
d = d[len("https://"):]
d = d[len("https://") :]
elif d.startswith("http://"):
d = d[len("http://"):]
d = d[len("http://") :]
return d

@property
Expand Down Expand Up @@ -65,7 +65,12 @@ def logout_url(self) -> str:
query = urlencode(
{
"client_id": self.client_id,
"logout_uri": self.logout_redirect_uri.rstrip("/"),
# Cognito managed-login logout validates redirect_uri against
# CallbackURLs. Using logout_uri can fall through to Cognito's
# generic hosted error page even when the local app can explain
# the contract mismatch more clearly.
"redirect_uri": self.redirect_uri.rstrip("/"),
"response_type": "code",
Comment on lines +72 to +73
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use post-logout URI instead of callback URI

Building the logout URL with redirect_uri=self.redirect_uri and response_type=code sends users into Cognito's managed-login reauthentication path after sign-out, which returns to /auth/callback without a Bloom-initiated state. Bloom's callback flow expects state from /auth/login (see the existing test_auth_callback_get_requires_prior_login_state behavior), and logout clears session state, so users who sign back in from that Cognito page can be bounced with invalid_state instead of completing login. This regresses the normal logout→login loop unless logout redirects back to a true post-logout URL (logout_uri) or a stateful flow is introduced.

Useful? React with 👍 / 👎.

}
)
return f"https://{self._bare_domain}/logout?{query}"
Expand Down Expand Up @@ -140,7 +145,7 @@ def from_settings(
required_client_name: str = "",
) -> "CognitoAuth":
"""Create CognitoAuth from BloomSettings.auth instance.

Args:
auth_settings: AuthSettings instance from bloom_lims.config
"""
Expand Down Expand Up @@ -235,7 +240,9 @@ def exchange_authorization_code(self, code: str) -> dict:
try:
token_payload = response.json()
except ValueError as exc:
raise CognitoTokenError("Token exchange returned non-JSON response") from exc
raise CognitoTokenError(
"Token exchange returned non-JSON response"
) from exc

if not token_payload.get("id_token") and not token_payload.get("access_token"):
raise CognitoTokenError("Token exchange returned no usable tokens")
Expand All @@ -258,8 +265,7 @@ def validate_token(self, token: str) -> dict:

@lru_cache(maxsize=1)
def get_cognito_auth() -> CognitoAuth:
"""Create (and cache) a CognitoAuth instance from YAML config.
"""
"""Create (and cache) a CognitoAuth instance from YAML config."""
from bloom_lims.config import get_settings

settings = get_settings()
Expand Down
50 changes: 47 additions & 3 deletions bloom_lims/gui/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,27 @@

router = APIRouter()

_AUTH_ERROR_MESSAGES: dict[str, str] = {
"authentication_failed": "Bloom could not complete authentication. Start sign-in again.",
"invalid_state": "Bloom could not verify the sign-in session. Start sign-in again.",
"missing_code": "Bloom did not receive an authorization code from Cognito. Start sign-in again.",
"missing_tokens": "Bloom did not receive the tokens required to complete sign-in.",
"token_exchange_failed": "Bloom could not exchange the Cognito authorization code for tokens. Start sign-in again.",
"invalid_cognito_token": "Bloom received an invalid Cognito token. Start sign-in again.",
"missing_email": "Bloom could not determine the email address for this sign-in.",
"email_domain_auth_disabled": "Bloom sign-in is disabled for this deployment's allowed email domains.",
"email_domain_not_allowed": "Your email domain is not allowed to access this Bloom deployment.",
"session_bootstrap_failed": "Bloom signed you in, but could not initialize your local access profile.",
"cognito_sign_in_misconfigured": (
"Bloom Cognito sign-in is misconfigured. The shared app client callback/logout URLs "
"or redirect URI do not match this Bloom deployment."
),
"cognito_logout_misconfigured": (
"Bloom cleared your local session, but the shared Cognito logout contract is misconfigured. "
"Update the shared app client redirect URLs for this Bloom deployment."
),
}


class SessionBootstrapError(RuntimeError):
"""Raised when Bloom cannot establish the post-login session context."""
Expand Down Expand Up @@ -79,7 +100,8 @@ async def auth_login(request: Request, next: str = "/"):
_next_path(next),
)
except CognitoConfigurationError as exc:
raise MissingCognitoEnvVarsException(str(exc)) from exc
logging.error("Bloom Cognito sign-in is misconfigured: %s", exc)
return _auth_error_redirect("cognito_sign_in_misconfigured", next_path=next)


def _auth_error_redirect(reason: str, *, next_path: str = "/") -> RedirectResponse:
Expand All @@ -91,6 +113,21 @@ def _auth_error_redirect(reason: str, *, next_path: str = "/") -> RedirectRespon
)


def _auth_error_message(reason: str | None) -> str:
normalized = str(reason or "").strip()
if not normalized:
return _AUTH_ERROR_MESSAGES["authentication_failed"]
mapped = _AUTH_ERROR_MESSAGES.get(normalized)
if mapped:
return mapped
if " " in normalized:
return normalized
fallback = normalized.replace("_", " ").strip()
if not fallback:
return _AUTH_ERROR_MESSAGES["authentication_failed"]
return fallback[:1].upper() + fallback[1:]


def _callback_error_response(error_message: str, *, status_code: int) -> JSONResponse:
msg = (error_message or "authentication_failed").strip()
return JSONResponse(content={"detail": msg}, status_code=status_code)
Expand Down Expand Up @@ -374,7 +411,7 @@ async def auth_error(
"udat": load_bloom_user_data(request) or {},
"cognito_login_url": f"/auth/login?next={quote(next_path, safe='/')}",
"auth_primary_href": f"/auth/login?next={quote(next_path, safe='/')}",
"auth_error": reason,
"auth_error": _auth_error_message(reason),
"version": "1.0.0",
}
return HTMLResponse(content=template.render(context), status_code=403)
Expand Down Expand Up @@ -403,10 +440,12 @@ async def _logout_response(request: Request, response: Response):
logging.warning("Logging out user: clearing session data")

cognito_logout_url = "/login"
logout_reason: str | None = None
try:
cognito_logout_url = _get_request_cognito_auth(request).config.logout_url
except CognitoConfigurationError as exc:
logging.error("Cognito configuration missing during logout: %s", exc)
logout_reason = "cognito_logout_misconfigured"

clear_bloom_session(request)
logging.info("User session cleared.")
Expand All @@ -418,7 +457,12 @@ async def _logout_response(request: Request, response: Response):
)

logout_response = RedirectResponse(
url=cognito_logout_url, status_code=status.HTTP_303_SEE_OTHER
url=(
_auth_error_redirect(logout_reason, next_path="/").headers["location"]
if logout_reason
else cognito_logout_url
),
status_code=status.HTTP_303_SEE_OTHER,
)
logout_response.delete_cookie("bloom_session", path="/")
logout_response.delete_cookie("session", path="/")
Expand Down
14 changes: 14 additions & 0 deletions tests/test_cognito_shared_pool_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from auth.cognito.client import CognitoAuth, CognitoConfigurationError


def _auth_settings(**overrides):
payload = {
"cognito_user_pool_id": "us-east-1_POOL123",
Expand Down Expand Up @@ -45,6 +46,19 @@ def test_authorize_url_uses_authorization_code_flow():
assert query["scope"] == ["openid email profile"]


def test_logout_url_uses_managed_login_redirect_contract():
auth = CognitoAuth.from_settings(_auth_settings())

parsed = urlparse(auth.config.logout_url)
query = parse_qs(parsed.query)

assert parsed.path == "/logout"
assert query["client_id"] == ["bloom-client"]
assert query["redirect_uri"] == ["https://localhost:8912/auth/callback"]
assert query["response_type"] == ["code"]
assert "logout_uri" not in query


def test_from_settings_rejects_missing_yaml_values():
with pytest.raises(CognitoConfigurationError) as exc:
CognitoAuth.from_settings(
Expand Down
52 changes: 52 additions & 0 deletions tests/test_gui_auth_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import main
from auth.cognito.client import CognitoConfigurationError
from bloom_lims.gui.routes.auth import SessionBootstrapError
from bloom_lims.gui.web_session import _normalize_user_data

Expand Down Expand Up @@ -242,6 +243,57 @@ def _explode(*_args, **_kwargs):
)


def test_auth_login_redirects_to_local_auth_error_when_cognito_is_misconfigured(
monkeypatch: pytest.MonkeyPatch, client: TestClient
) -> None:
monkeypatch.setattr(
"bloom_lims.gui.routes.auth.clear_bloom_session", lambda _request: None
)
monkeypatch.setattr(
"bloom_lims.gui.routes.auth.build_bloom_web_session_config",
lambda _request: (_ for _ in ()).throw(
CognitoConfigurationError("callback/logout mismatch")
),
)

response = client.get("/auth/login?next=/worksets", follow_redirects=False)

assert response.status_code in (302, 303)
assert (
response.headers["location"]
== "/auth/error?reason=cognito_sign_in_misconfigured&next=/worksets"
)


def test_auth_error_renders_human_readable_message(client: TestClient) -> None:
response = client.get(
"/auth/error?reason=cognito_logout_misconfigured",
follow_redirects=False,
)

assert response.status_code == 403
assert "Bloom cleared your local session" in response.text


def test_auth_logout_redirects_to_local_auth_error_when_cognito_is_misconfigured(
monkeypatch: pytest.MonkeyPatch, client: TestClient
) -> None:
monkeypatch.setattr(
"bloom_lims.gui.routes.auth._get_request_cognito_auth",
lambda _request: (_ for _ in ()).throw(
CognitoConfigurationError("logout redirect mismatch")
),
)

response = client.get("/auth/logout", follow_redirects=False)

assert response.status_code in (302, 303)
assert (
response.headers["location"]
== "/auth/error?reason=cognito_logout_misconfigured&next=/"
)


def test_auth_callback_post_requires_tokens(client: TestClient) -> None:
response = client.post("/auth/callback", json={})

Expand Down
Loading