Compare commits

...

2 Commits

Author SHA1 Message Date
openhands 666d475843 Fix lint issues: import order, single quotes, trailing newline
- Sort imports alphabetically (keycloak before pydantic)
- Use single quotes instead of double quotes per ruff style
- Add newline at end of file
- Fix import grouping in middleware.py

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-07 16:42:20 +00:00
John-Mason Shackelford 5588738e41 Improve error message for expired Keycloak sessions during API key auth
When a user's API key is valid but their Keycloak offline session has
expired or been revoked, the error message was previously a generic
'BearerTokenError' which didn't help users understand how to fix the
issue.

This change catches the Keycloak session expiration error and raises
the existing SessionExpiredError (from openhands.server.types) with a
clear, actionable message explaining that the user needs to log in via
the web UI to re-establish their session.

## Problem

When using an API key (e.g., sk-oh-...), authentication can fail with:
```json
{"error": "BearerTokenError"}
```

The actual underlying error was:
```
KeycloakPostError: 400: {"error":"invalid_grant","error_description":"Offline user session not found"}
```

This was confusing because:
1. The API key itself was valid
2. The error message didn't indicate what was wrong
3. Users had no idea how to fix it

## Solution

Now the API returns:
```json
{
  "error": "SessionExpired",
  "message": "Your session has expired. Please log in at https://app.all-hands.dev to re-authenticate, then retry your request."
}
```

Changes:
- Reuse existing SessionExpiredError from openhands.server.types
  (addresses reviewer feedback to avoid duplicating error types)
- Catch KeycloakError in saas_user_auth_from_bearer and check for
  session expiration indicators (invalid_grant, session not found)
- Add middleware handler for SessionExpiredError with descriptive JSON
- Add tests for the new error handling

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-07 12:38:34 -04:00
3 changed files with 89 additions and 0 deletions
+13
View File
@@ -35,6 +35,7 @@ from openhands.integrations.provider import (
ProviderType,
)
from openhands.server.settings import Settings
from openhands.server.types import SessionExpiredError
from openhands.server.user_auth.user_auth import AuthType, UserAuth
from openhands.storage.data_models.secrets import Secrets
from openhands.storage.settings.settings_store import SettingsStore
@@ -313,6 +314,18 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
)
await saas_user_auth.refresh()
return saas_user_auth
except KeycloakError as exc:
# Check for session expiration errors from Keycloak
error_str = str(exc)
if 'invalid_grant' in error_str or 'session not found' in error_str.lower():
logger.warning(
'API key authentication failed due to expired session',
extra={'error': error_str},
)
raise SessionExpiredError(
'Your session has expired. Please log in at https://app.all-hands.dev to re-authenticate, then retry your request.'
) from exc
raise BearerTokenError from exc
except Exception as exc:
raise BearerTokenError from exc
+11
View File
@@ -16,6 +16,7 @@ from server.routes.auth import set_response_cookie
from server.utils.url_utils import get_cookie_domain, get_cookie_samesite
from openhands.core.logger import openhands_logger as logger
from openhands.server.types import SessionExpiredError
from openhands.server.user_auth.user_auth import AuthType, UserAuth, get_user_auth
from openhands.server.utils import config
@@ -76,6 +77,16 @@ class SetAuthCookieMiddleware:
return JSONResponse(
{'error': str(e) or e.__class__.__name__}, status.HTTP_401_UNAUTHORIZED
)
except SessionExpiredError as e:
logger.info('session_expired', extra={'message': str(e)})
# Return a helpful error message explaining how to fix the issue
return JSONResponse(
{
'error': 'SessionExpired',
'message': str(e),
},
status.HTTP_401_UNAUTHORIZED,
)
except AuthError as e:
logger.warning('auth_error', exc_info=True)
try:
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import jwt
import pytest
from fastapi import Request
from keycloak.exceptions import KeycloakPostError
from pydantic import SecretStr
from server.auth.auth_error import (
AuthError,
@@ -23,6 +24,7 @@ from storage.api_key_store import ApiKeyValidationResult
from storage.user_authorization import UserAuthorizationType
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.server.types import SessionExpiredError
from openhands.storage.data_models.secrets import Secrets
@@ -910,3 +912,66 @@ async def test_saas_user_auth_from_signed_token_domain_blocking_inactive(mock_co
mock_user_auth_store.get_authorization_type.assert_called_once_with(
'user@colsch.us', None
)
@pytest.mark.asyncio
async def test_saas_user_auth_from_bearer_session_expired():
"""Test that saas_user_auth_from_bearer raises SessionExpiredError on Keycloak session expiration."""
mock_request = MagicMock()
mock_request.headers = {'Authorization': 'Bearer test_api_key'}
with patch('server.auth.saas_user_auth.ApiKeyStore') as mock_api_key_store_cls:
mock_api_key_store = MagicMock()
mock_api_key_store_cls.get_instance.return_value = mock_api_key_store
mock_api_key_store.validate_api_key = AsyncMock(
return_value=ApiKeyValidationResult(
user_id='test_user_id',
org_id=uuid.uuid4(),
key_id=1,
key_name='test_key',
)
)
with patch('server.auth.saas_user_auth.token_manager') as mock_token_manager:
# Simulate Keycloak session expired error
mock_token_manager.load_offline_token = AsyncMock(
side_effect=KeycloakPostError(
error_message=b'{"error":"invalid_grant","error_description":"Offline user session not found"}'
)
)
with pytest.raises(SessionExpiredError) as exc_info:
await saas_user_auth_from_bearer(mock_request)
assert 'session has expired' in str(exc_info.value).lower()
assert 'https://app.all-hands.dev' in str(exc_info.value)
@pytest.mark.asyncio
async def test_saas_user_auth_from_bearer_other_keycloak_error():
"""Test that saas_user_auth_from_bearer raises BearerTokenError on other Keycloak errors."""
mock_request = MagicMock()
mock_request.headers = {'Authorization': 'Bearer test_api_key'}
with patch('server.auth.saas_user_auth.ApiKeyStore') as mock_api_key_store_cls:
mock_api_key_store = MagicMock()
mock_api_key_store_cls.get_instance.return_value = mock_api_key_store
mock_api_key_store.validate_api_key = AsyncMock(
return_value=ApiKeyValidationResult(
user_id='test_user_id',
org_id=uuid.uuid4(),
key_id=1,
key_name='test_key',
)
)
with patch('server.auth.saas_user_auth.token_manager') as mock_token_manager:
# Simulate a different Keycloak error (not session expired)
mock_token_manager.load_offline_token = AsyncMock(
side_effect=KeycloakPostError(
error_message=b'{"error":"server_error","error_description":"Internal server error"}'
)
)
with pytest.raises(BearerTokenError):
await saas_user_auth_from_bearer(mock_request)