diff --git a/docs/content/Deploying/Settings-Reference.mdx b/docs/content/Deploying/Settings-Reference.mdx index 59b20430..88e4149d 100644 --- a/docs/content/Deploying/Settings-Reference.mdx +++ b/docs/content/Deploying/Settings-Reference.mdx @@ -1111,6 +1111,12 @@ Public callback URL for MCP OAuth; unset derives it from CONNECTOR_REDIRECT_BASE Serving the UI, public URLs, and process-level knobs of the API server. +### `DEPLOYMENT_TYPE` + +Type `str`, default unset. + +Deployment class, e.g. cloud or production. A production class refuses to run without a configured JWT_SECRET_KEY instead of generating a local one on disk. + ### `SERVE_UI` Type `bool`, default `true`. diff --git a/docsgpt/app.py b/docsgpt/app.py index 0913218f..4a11937e 100644 --- a/docsgpt/app.py +++ b/docsgpt/app.py @@ -1,5 +1,4 @@ import logging -import os import platform import uuid @@ -170,16 +169,8 @@ def enforce_document_upload_request_size_limit(): # only local development may use the atomic filesystem fallback. settings.JWT_SECRET_KEY = resolve_jwt_secret_key( settings.JWT_SECRET_KEY, - os.getenv("DEPLOYMENT_TYPE"), + settings.DEPLOYMENT_TYPE, ) -if settings.AUTH_TYPE == "oidc": - _missing_oidc = [ - name - for name in ("OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_FRONTEND_URL") - if not getattr(settings, name) - ] - if _missing_oidc: - raise RuntimeError(f"AUTH_TYPE=oidc requires settings: {', '.join(_missing_oidc)}") SIMPLE_JWT_TOKEN = None if settings.AUTH_TYPE == "simple_jwt": payload = {"sub": "local"} diff --git a/docsgpt/core/settings/auth.py b/docsgpt/core/settings/auth.py index 10e69801..04b039bc 100644 --- a/docsgpt/core/settings/auth.py +++ b/docsgpt/core/settings/auth.py @@ -4,11 +4,15 @@ from __future__ import annotations from typing import Literal, Optional -from pydantic import Field, field_validator +from pydantic import Field, field_validator, model_validator from docsgpt.core.settings._shared import SettingsGroup, normalize_choice, normalize_secret +#: Settings an OIDC deployment cannot run without; checked when AUTH_TYPE=oidc. +OIDC_REQUIRED = ("OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_FRONTEND_URL") + + class AuthSettings(SettingsGroup): """How users authenticate: none, a shared token, per-session JWTs, or OIDC SSO.""" @@ -91,3 +95,11 @@ class AuthSettings(SettingsGroup): def _normalize_auth_type(cls, v): # ``AUTH_TYPE=None`` and ``AUTH_TYPE=`` in .env both mean "no authentication". return normalize_choice(normalize_secret(v)) + + @model_validator(mode="after") + def _require_oidc_settings(self): + if self.AUTH_TYPE == "oidc": + missing = [name for name in OIDC_REQUIRED if not getattr(self, name)] + if missing: + raise ValueError(f"AUTH_TYPE=oidc requires settings: {', '.join(missing)}") + return self diff --git a/docsgpt/core/settings/server.py b/docsgpt/core/settings/server.py index afb79121..407ef4aa 100644 --- a/docsgpt/core/settings/server.py +++ b/docsgpt/core/settings/server.py @@ -12,6 +12,13 @@ from docsgpt.core.settings._shared import SettingsGroup class ServerSettings(SettingsGroup): """Serving the UI, public URLs, and process-level knobs of the API server.""" + DEPLOYMENT_TYPE: Optional[str] = Field( + default=None, + description=( + "Deployment class, e.g. cloud or production. A production class refuses to run without a " + "configured JWT_SECRET_KEY instead of generating a local one on disk." + ), + ) SERVE_UI: bool = Field( default=True, description="Serve the web UI shipped in the package (docsgpt/static) from the API process." ) diff --git a/tests/core/test_settings.py b/tests/core/test_settings.py index a4d64bf3..0757d8a4 100644 --- a/tests/core/test_settings.py +++ b/tests/core/test_settings.py @@ -110,6 +110,21 @@ class TestReference: ) +@pytest.mark.unit +class TestCrossFieldRules: + OIDC = {"OIDC_ISSUER": "https://idp.example/", "OIDC_CLIENT_ID": "docsgpt", "OIDC_FRONTEND_URL": "http://app"} + + def test_oidc_requires_issuer_client_and_frontend(self): + with pytest.raises(ValidationError, match="AUTH_TYPE=oidc requires settings: OIDC_CLIENT_ID, OIDC_FRONTEND_URL"): + Settings.model_validate({"AUTH_TYPE": "oidc", "OIDC_ISSUER": self.OIDC["OIDC_ISSUER"]}) + + def test_oidc_with_required_settings_loads(self): + assert Settings.model_validate({"AUTH_TYPE": "OIDC", **self.OIDC}).AUTH_TYPE == "oidc" + + def test_oidc_settings_are_not_required_for_other_modes(self): + assert Settings.model_validate({"AUTH_TYPE": "session_jwt"}).OIDC_ISSUER is None + + @pytest.mark.unit class TestClosedChoices: """Enum-like settings are Literal types: a typo fails at startup instead of falling through.""" @@ -121,7 +136,7 @@ class TestClosedChoices: @pytest.mark.parametrize( ("name", "raw", "expected"), [ - ("AUTH_TYPE", " OIDC ", "oidc"), + ("AUTH_TYPE", " Session_JWT ", "session_jwt"), ("VECTOR_STORE", "PGVector", "pgvector"), ("STORAGE_TYPE", "S3", "s3"), ("URL_STRATEGY", "Backend", "backend"),