diff --git a/.jules/palette.md b/.jules/palette.md index 1ba61391..5170454c 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -7,3 +7,7 @@ **Learning:** 백엔드 전용 프로젝트(프론트엔드가 없는 경우)에서는 'UX(사용자 경험)'가 주로 'DX(개발자 경험)'로 해석됩니다. OpenAPI/Swagger 스키마에 `json_schema_extra={"example": ...}`와 같은 구체적인 예시를 추가하면 API를 사용하는 개발자들의 인터페이스 이해도를 높일 수 있습니다. **Action:** 향후 백엔드 API 중심의 프로젝트에서는 Pydantic 스키마 정의에 풍부한 문서화와 예제 데이터가 포함되어 있는지 확인하여 개발자 경험을 개선할 것입니다. +## 2026-09-04 - Enable persistAuthorization in development + +**Learning:** Developers frequently test authenticated endpoints via Swagger UI in local development, but refreshing the page clears the API token, degrading the developer experience. Adding `persistAuthorization: True` improves usability by caching credentials locally. +**Action:** Always enable `persistAuthorization` conditionally based on the runtime profile (e.g., `development`) to optimize DX without compromising production security. diff --git a/README.md b/README.md index 4b5c1674..feb6a226 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,8 @@ NEWSDOM_RUNTIME_PROFILE=development \ uv run uvicorn --app-dir src newsdom_api.main:app --reload ``` +In development, you may also optionally enable Swagger UI authorization persistence by exporting `NEWSDOM_SWAGGER_PERSIST_AUTHORIZATION=true`. This caches credentials in the browser so they survive page close/refresh. This setting is strictly for local development and should **not** be enabled on shared or remote development browsers, as it risks exposing authorization data to other users of that browser. It is rejected in production profiles. + Do not use the development-only bypass as a production rollback. Restore a working secret or the previous release instead. diff --git a/src/newsdom_api/config.py b/src/newsdom_api/config.py index 34bd05f9..fefc728c 100644 --- a/src/newsdom_api/config.py +++ b/src/newsdom_api/config.py @@ -11,6 +11,7 @@ AUTH_MODE_ENV_VAR = "NEWSDOM_AUTH_MODE" RUNTIME_PROFILE_ENV_VAR = "NEWSDOM_RUNTIME_PROFILE" MAX_BEARER_HEADER_BYTES = 4096 +PERSIST_AUTHORIZATION_ENV_VAR = "NEWSDOM_SWAGGER_PERSIST_AUTHORIZATION" class RuntimeConfigurationError(ValueError): @@ -38,6 +39,7 @@ class RuntimeSettings: authentication_mode: AuthenticationMode = AuthenticationMode.REQUIRED runtime_profile: RuntimeProfile = RuntimeProfile.PRODUCTION api_token: str | None = field(default=None, repr=False) + persist_authorization: bool = False def __post_init__(self) -> None: """Normalize secrets once and reject unsafe direct construction.""" @@ -50,6 +52,11 @@ def __post_init__(self) -> None: "Authentication can be disabled only in the development runtime profile" ) + if self.persist_authorization and self.runtime_profile is not RuntimeProfile.DEVELOPMENT: + raise RuntimeConfigurationError( + "Authorization persistence can be enabled only in the development runtime profile" + ) + if self.api_token is None: return @@ -132,8 +139,10 @@ def load_runtime_settings( ) ) + persist_authorization = values.get(PERSIST_AUTHORIZATION_ENV_VAR, "false").strip().lower() == "true" return RuntimeSettings( authentication_mode=authentication_mode, runtime_profile=runtime_profile, api_token=get_api_token(values), + persist_authorization=persist_authorization, ) diff --git a/src/newsdom_api/main.py b/src/newsdom_api/main.py index f61aafc2..19472f07 100644 --- a/src/newsdom_api/main.py +++ b/src/newsdom_api/main.py @@ -303,6 +303,14 @@ def create_app( elif not application_settings.authentication_ready: LOGGER.error("Parser authentication configuration is unavailable") + swagger_ui_params = { + "displayRequestDuration": True, + "syntaxHighlight.theme": "monokai", + "tryItOutEnabled": True, + } + if application_settings.persist_authorization: + swagger_ui_params["persistAuthorization"] = True + application = FastAPI( title="NewsDOM API", description=( @@ -317,11 +325,7 @@ def create_app( }, license_info={"name": "MIT License", "identifier": "MIT"}, openapi_tags=tags_metadata, - swagger_ui_parameters={ - "displayRequestDuration": True, - "syntaxHighlight.theme": "monokai", - "tryItOutEnabled": True, - }, + swagger_ui_parameters=swagger_ui_params, ) application.state.runtime_settings = application_settings application.state.runtime_readiness_probe = ( diff --git a/tests/test_fastapi_dx.py b/tests/test_fastapi_dx.py new file mode 100644 index 00000000..b3b6d5b0 --- /dev/null +++ b/tests/test_fastapi_dx.py @@ -0,0 +1,71 @@ +from fastapi.testclient import TestClient +from newsdom_api.main import app +from newsdom_api.config import AuthenticationMode, RuntimeProfile, RuntimeSettings, load_runtime_settings +from newsdom_api.main import create_app + +import pytest +from newsdom_api.config import RuntimeConfigurationError + +def test_development_profile_persists_authorization_default_off(): + """Verify persistAuthorization is off by default even in development profile.""" + settings = RuntimeSettings( + authentication_mode=AuthenticationMode.DISABLED, + runtime_profile=RuntimeProfile.DEVELOPMENT, + ) + app = create_app(settings) + assert app.swagger_ui_parameters.get("persistAuthorization") is None + +def test_development_profile_persists_authorization_opt_in(): + """Verify persistAuthorization is enabled in development profile when explicitly requested.""" + settings = RuntimeSettings( + authentication_mode=AuthenticationMode.DISABLED, + runtime_profile=RuntimeProfile.DEVELOPMENT, + persist_authorization=True, + ) + app = create_app(settings) + assert app.swagger_ui_parameters.get("persistAuthorization") is True + +def test_production_profile_does_not_persist_authorization(): + """Verify persistAuthorization is off by default in production profile.""" + settings = RuntimeSettings( + authentication_mode=AuthenticationMode.REQUIRED, + runtime_profile=RuntimeProfile.PRODUCTION, + api_token="test_token" + ) + app = create_app(settings) + assert app.swagger_ui_parameters.get("persistAuthorization") is None + +def test_production_profile_rejects_persist_authorization(): + """Verify persistAuthorization explicitly requested in production fails closed.""" + with pytest.raises(RuntimeConfigurationError, match="development runtime profile"): + RuntimeSettings( + authentication_mode=AuthenticationMode.REQUIRED, + runtime_profile=RuntimeProfile.PRODUCTION, + api_token="test_token", + persist_authorization=True, + ) + +def test_load_settings_parses_persist_authorization_env_var(): + """Verify persist_authorization is loaded from NEWSDOM_SWAGGER_PERSIST_AUTHORIZATION.""" + env = { + "NEWSDOM_AUTH_MODE": "disabled", + "NEWSDOM_RUNTIME_PROFILE": "development", + "NEWSDOM_SWAGGER_PERSIST_AUTHORIZATION": "true" + } + settings = load_runtime_settings(env) + assert settings.persist_authorization is True + +def test_production_profile_auth_readiness_is_unchanged(): + """Verify unchanged production auth readiness.""" + settings = RuntimeSettings( + authentication_mode=AuthenticationMode.REQUIRED, + runtime_profile=RuntimeProfile.PRODUCTION, + api_token="test_token" + ) + assert settings.authentication_ready is True + + settings_missing_token = RuntimeSettings( + authentication_mode=AuthenticationMode.REQUIRED, + runtime_profile=RuntimeProfile.PRODUCTION, + ) + assert settings_missing_token.authentication_ready is False