Skip to content

Commit 79a5829

Browse files
authored
feat: add conditional authentication provider configuration (#5002)
Adds support for conditionally enabling/disabling authentication via environment variables, following the same pattern as inference providers. Key features: - Auth provider can be enabled/disabled using ${env.AUTH_PROVIDER:+oauth2_token} syntax in config.yaml - When AUTH_PROVIDER env var is set, auth is enabled; when unset, auth is completely disabled (no middleware initialized) - Allows same config.yaml to work across dev/staging/prod environments - Added to ci-tests distribution as example and for testing Implementation: - Special handling in replace_env_vars() intercepts auth config before Pydantic validation - When type field resolves to None/empty, entire provider_config is set to None to avoid discriminated union validation errors - remove auth provider_config during list-deps, it isn't used and as config hasn't passed through replace_env_vars fails syntax checks Changes: - src/llama_stack/core/stack.py: Add conditional auth handling - src/llama_stack/distributions/template.py: Add auth_config field to RunConfigSettings - src/llama_stack/distributions/ci-tests/: Add conditional auth config - docs/docs/distributions/configuration.mdx: Document conditional auth - tests/unit/server/test_replace_env_vars.py: Add tests - src/llama_stack/cli/stack/_list_deps.py: remove auth provider_config Related to #4365 Signed-off-by: Derek Higgins <derekh@redhat.com>
1 parent b80cda7 commit 79a5829

8 files changed

Lines changed: 227 additions & 0 deletions

File tree

docs/docs/distributions/configuration.mdx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,36 @@ The `auth` section configures authentication for the server. When configured, al
288288
Authorization: Bearer <token>
289289
```
290290

291+
#### Conditional Authentication
292+
293+
Authentication can be conditionally enabled or disabled using environment variables with the conditional syntax (`:+`). This is useful for deploying the same configuration to different environments where auth may or may not be required.
294+
295+
Example:
296+
```yaml
297+
server:
298+
auth:
299+
provider_config:
300+
type: ${env.AUTH_PROVIDER:+oauth2_token}
301+
audience: "llama-stack"
302+
jwks:
303+
uri: ${env.KEYCLOAK_URL}/realms/llamastack/protocol/openid-connect/certs
304+
issuer: ${env.KEYCLOAK_URL}/realms/llamastack
305+
```
306+
307+
**Behavior:**
308+
- **If `AUTH_PROVIDER` is set** (to any value): Authentication is enabled with OAuth2
309+
- **If `AUTH_PROVIDER` is NOT set**: Authentication is completely disabled (no middleware added)
310+
311+
This allows you to:
312+
- Run without authentication in local development (unset the env var)
313+
- Enable authentication in staging/production (set the env var)
314+
- Use the same config.yaml across all environments
315+
316+
**Important Notes:**
317+
- The `type` field uses the conditional syntax to control whether the entire auth provider is enabled
318+
- When the env var is not set, the entire `provider_config` is set to `None` and no authentication middleware is initialized
319+
- Other auth config fields (like `route_policy`) can still be used independently when `provider_config` is disabled
320+
291321
The server supports multiple authentication providers:
292322

293323
#### OAuth 2.0/OpenID Connect Provider with Kubernetes

src/llama_stack/cli/stack/_list_deps.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ def run_stack_list_deps_command(args: argparse.Namespace) -> None:
8181
with open(config_file) as f:
8282
try:
8383
contents = yaml.safe_load(f)
84+
# Remove auth provider_config to avoid validation errors with env var syntax.
85+
# We only need provider dependencies, not auth config (auth has no pip_packages).
86+
# This is simpler than modifying the schema to accept type="" which would require
87+
# removing discriminated union and adding custom validation logic and modifying
88+
# all 4 auth provider config classes (a very invasive change)
89+
if "server" in contents and "auth" in contents["server"]:
90+
if "provider_config" in contents["server"]["auth"]:
91+
contents["server"]["auth"]["provider_config"] = None
8492
config = StackConfig(**contents)
8593
except Exception as e:
8694
cprint(

src/llama_stack/core/stack.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,35 @@ def __init__(self, var_name: str, path: str = ""):
386386

387387
def replace_env_vars(config: Any, path: str = "") -> Any:
388388
if isinstance(config, dict):
389+
# Special handling for auth provider_config with conditional type field
390+
# This allows auth to be enabled/disabled via environment variables
391+
# Example: type: ${env.AUTH_PROVIDER:+oauth2_token}
392+
if "provider_config" in config and path == "server.auth":
393+
provider_cfg = config.get("provider_config")
394+
if isinstance(provider_cfg, dict) and "type" in provider_cfg:
395+
try:
396+
# Resolve the type field first to check if auth should be enabled
397+
resolved_type = replace_env_vars(provider_cfg["type"], f"{path}.provider_config.type")
398+
399+
# If type is empty/None, disable auth by setting provider_config to None
400+
# This prevents validation errors on the discriminated union
401+
if resolved_type is None or resolved_type == "":
402+
# Process rest of config normally but exclude provider_config from expansion
403+
# to avoid EnvVarError from bare env vars (e.g., ${env.KEYCLOAK_URL})
404+
result = {
405+
k: replace_env_vars(v, f"{path}.{k}" if path else k)
406+
for k, v in config.items()
407+
if k != "provider_config"
408+
}
409+
result["provider_config"] = None
410+
return result
411+
except EnvVarError as e:
412+
# If we can't resolve type, continue with normal processing
413+
# and let validation catch the error
414+
logger.debug(
415+
f"Could not resolve auth provider type field: {e.var_name} - continuing with normal processing"
416+
)
417+
389418
result = {}
390419
for k, v in config.items():
391420
try:

src/llama_stack/distributions/ci-tests/ci_tests.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,25 @@ def get_distribution_template() -> DistributionTemplate:
3434
url="http://localhost:5199/sse",
3535
)
3636

37+
# Add conditional authentication config (disabled by default for CI tests)
38+
# This tests the conditional auth provider feature and provides a template for users
39+
# To enable: export AUTH_PROVIDER=enabled and configure the auth env vars
40+
auth_config = {
41+
# Authentication is disabled by default (AUTH_PROVIDER not set)
42+
# To enable: export AUTH_PROVIDER=enabled
43+
# Then configure the required auth provider settings below
44+
"provider_config": {
45+
"type": "${env.AUTH_PROVIDER:+oauth2_token}",
46+
"audience": "${env.AUTH_AUDIENCE:=llama-stack}",
47+
"issuer": "${env.AUTH_ISSUER:=}",
48+
"jwks": {
49+
"uri": "${env.AUTH_JWKS_URI:=}",
50+
"key_recheck_period": "${env.AUTH_JWKS_RECHECK_PERIOD:=3600}",
51+
},
52+
"verify_tls": "${env.AUTH_VERIFY_TLS:=true}",
53+
}
54+
}
55+
3756
for run_config in template.run_configs.values():
3857
if run_config.default_models is None:
3958
run_config.default_models = []
@@ -43,4 +62,7 @@ def get_distribution_template() -> DistributionTemplate:
4362
run_config.default_connectors = []
4463
run_config.default_connectors.append(test_mcp_connector)
4564

65+
# Add conditional auth config
66+
run_config.auth_config = auth_config
67+
4668
return template

src/llama_stack/distributions/ci-tests/config.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,15 @@ registered_resources:
300300
provider_id: rag-runtime
301301
server:
302302
port: 8321
303+
auth:
304+
provider_config:
305+
type: ${env.AUTH_PROVIDER:+oauth2_token}
306+
audience: ${env.AUTH_AUDIENCE:=llama-stack}
307+
issuer: ${env.AUTH_ISSUER:=}
308+
jwks:
309+
uri: ${env.AUTH_JWKS_URI:=}
310+
key_recheck_period: ${env.AUTH_JWKS_RECHECK_PERIOD:=3600}
311+
verify_tls: ${env.AUTH_VERIFY_TLS:=true}
303312
vector_stores:
304313
default_provider_id: faiss
305314
default_embedding_model:

src/llama_stack/distributions/ci-tests/run-with-postgres-store.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,15 @@ registered_resources:
309309
provider_id: rag-runtime
310310
server:
311311
port: 8321
312+
auth:
313+
provider_config:
314+
type: ${env.AUTH_PROVIDER:+oauth2_token}
315+
audience: ${env.AUTH_AUDIENCE:=llama-stack}
316+
issuer: ${env.AUTH_ISSUER:=}
317+
jwks:
318+
uri: ${env.AUTH_JWKS_URI:=}
319+
key_recheck_period: ${env.AUTH_JWKS_RECHECK_PERIOD:=3600}
320+
verify_tls: ${env.AUTH_VERIFY_TLS:=true}
312321
vector_stores:
313322
default_provider_id: faiss
314323
default_embedding_model:

src/llama_stack/distributions/template.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ class RunConfigSettings(BaseModel):
184184
default_connectors: list[ConnectorInput] | None = None
185185
vector_stores_config: VectorStoresConfig | None = None
186186
safety_config: SafetyConfig | None = None
187+
auth_config: dict[str, Any] | None = None
187188
storage_backends: dict[str, Any] | None = None
188189
storage_stores: dict[str, Any] | None = None
189190

@@ -289,6 +290,9 @@ def run_config(
289290
},
290291
}
291292

293+
if self.auth_config:
294+
config["server"]["auth"] = self.auth_config
295+
292296
if self.vector_stores_config:
293297
config["vector_stores"] = self.vector_stores_config.model_dump(exclude_none=True)
294298

tests/unit/server/test_replace_env_vars.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,119 @@ def test_multiple_resources_with_conditional_ids(setup_env_vars):
185185
assert len(result["models"]) == 0
186186
finally:
187187
del os.environ["INCLUDE_BENCHMARK"]
188+
189+
190+
def test_auth_provider_disabled_when_type_not_set(setup_env_vars):
191+
"""Test that auth provider_config is set to None when type field is conditional and env var not set."""
192+
data = {
193+
"server": {
194+
"auth": {
195+
"provider_config": {
196+
"type": "${env.AUTH_PROVIDER:+oauth2_token}",
197+
"audience": "llama-stack",
198+
"issuer": "https://auth.example.com",
199+
},
200+
"route_policy": [],
201+
}
202+
}
203+
}
204+
# AUTH_PROVIDER is not set, so provider_config should become None
205+
result = replace_env_vars(data, "")
206+
assert result["server"]["auth"]["provider_config"] is None
207+
# route_policy should still be present
208+
assert result["server"]["auth"]["route_policy"] == []
209+
210+
211+
def test_auth_provider_enabled_when_type_is_set(setup_env_vars):
212+
"""Test that auth provider_config is preserved when type field is set via env var."""
213+
os.environ["AUTH_PROVIDER"] = "yes"
214+
try:
215+
data = {
216+
"server": {
217+
"auth": {
218+
"provider_config": {
219+
"type": "${env.AUTH_PROVIDER:+oauth2_token}",
220+
"audience": "llama-stack",
221+
"issuer": "https://auth.example.com",
222+
},
223+
"route_policy": [],
224+
}
225+
}
226+
}
227+
result = replace_env_vars(data, "")
228+
# AUTH_PROVIDER is set, so provider_config should be preserved with resolved type
229+
assert result["server"]["auth"]["provider_config"] is not None
230+
assert result["server"]["auth"]["provider_config"]["type"] == "oauth2_token"
231+
assert result["server"]["auth"]["provider_config"]["audience"] == "llama-stack"
232+
assert result["server"]["auth"]["provider_config"]["issuer"] == "https://auth.example.com"
233+
finally:
234+
del os.environ["AUTH_PROVIDER"]
235+
236+
237+
def test_auth_provider_disabled_when_type_is_empty(setup_env_vars):
238+
"""Test that auth provider_config is set to None when type field resolves to empty string."""
239+
data = {
240+
"server": {
241+
"auth": {
242+
"provider_config": {
243+
"type": "${env.NOT_SET:=}",
244+
"audience": "llama-stack",
245+
},
246+
"route_policy": [],
247+
}
248+
}
249+
}
250+
# NOT_SET env var is not set, and default is empty, so provider_config should become None
251+
result = replace_env_vars(data, "")
252+
assert result["server"]["auth"]["provider_config"] is None
253+
254+
255+
def test_auth_provider_with_hardcoded_type(setup_env_vars):
256+
"""Test that auth provider_config with hardcoded type is preserved."""
257+
data = {
258+
"server": {
259+
"auth": {
260+
"provider_config": {
261+
"type": "oauth2_token",
262+
"audience": "llama-stack",
263+
"issuer": "https://auth.example.com",
264+
},
265+
"route_policy": [],
266+
}
267+
}
268+
}
269+
result = replace_env_vars(data, "")
270+
# Hardcoded type should be preserved as-is
271+
assert result["server"]["auth"]["provider_config"] is not None
272+
assert result["server"]["auth"]["provider_config"]["type"] == "oauth2_token"
273+
assert result["server"]["auth"]["provider_config"]["audience"] == "llama-stack"
274+
275+
276+
def test_auth_provider_with_complex_config(setup_env_vars):
277+
"""Test conditional auth with complex nested config."""
278+
os.environ["ENABLE_AUTH"] = "true"
279+
os.environ["KEYCLOAK_URL"] = "http://keycloak:8080"
280+
try:
281+
data = {
282+
"server": {
283+
"auth": {
284+
"provider_config": {
285+
"type": "${env.ENABLE_AUTH:+oauth2_token}",
286+
"audience": "account",
287+
"issuer": "${env.KEYCLOAK_URL}/realms/llamastack",
288+
"jwks": {"uri": "${env.KEYCLOAK_URL}/realms/llamastack/protocol/openid-connect/certs"},
289+
}
290+
}
291+
}
292+
}
293+
result = replace_env_vars(data, "")
294+
assert result["server"]["auth"]["provider_config"] is not None
295+
assert result["server"]["auth"]["provider_config"]["type"] == "oauth2_token"
296+
assert result["server"]["auth"]["provider_config"]["issuer"] == "http://keycloak:8080/realms/llamastack"
297+
assert (
298+
result["server"]["auth"]["provider_config"]["jwks"]["uri"]
299+
== "http://keycloak:8080/realms/llamastack/protocol/openid-connect/certs"
300+
)
301+
finally:
302+
del os.environ["ENABLE_AUTH"]
303+
del os.environ["KEYCLOAK_URL"]

0 commit comments

Comments
 (0)