Skip to content

Commit 022a00f

Browse files
committed
Merge remote-tracking branch 'upstream/main' into feat-default-letsgo-to-localhost
Signed-off-by: Matthew Farrellee <matt@cs.wisc.edu> # Conflicts: # src/ogx/cli/stack/lets_go.py
2 parents ca35a6a + 3a71a54 commit 022a00f

8 files changed

Lines changed: 268 additions & 2 deletions

File tree

docs/docs/distributions/configuration.mdx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,24 @@ This allows you to:
308308

309309
The server supports multiple authentication providers:
310310

311+
#### Local API Key Provider
312+
313+
Validates a shared secret API key against a list of allowed keys:
314+
315+
```yaml
316+
server:
317+
auth:
318+
provider_config:
319+
type: "local_api_key"
320+
api_keys:
321+
- "ogk_mykey1"
322+
- "ogk_mykey2"
323+
```
324+
325+
This is the simplest authentication provider — any key in the list authenticates successfully and is granted `admin` and `owner` [roles](../configuration.mdx#access-control) so the default owner-based access-controls work out of the box. Use it for development, internal services, or when you prefer to manage keys outside an identity provider.
326+
327+
The token is validated as `Authorization: Bearer <key>` and the key string itself becomes the user principal. Because this provider does not resolve a `tenant_id`, it is only compatible with `server.tenancy.mode` `single` or `disabled` — startup will fail with `multi`.
328+
311329
#### OAuth 2.0/OpenID Connect Provider with Kubernetes
312330

313331
The server can be configured to use service account tokens for authorization, validating these against the Kubernetes API server, e.g.:

scripts/check_file_size.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
# Remove entries from this list as files get refactored.
2727
GRANDFATHERED_FILES = {
2828
"scripts/openapi_generator/schema_transforms.py",
29+
"src/ogx/cli/stack/lets_go.py",
2930
"src/ogx/core/datatypes.py",
3031
"src/ogx/core/library_client.py",
3132
"src/ogx/providers/inline/responses/builtin/responses/openai_responses.py",

src/ogx/cli/stack/lets_go.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import inspect
1313
import logging # allow-direct-logging :: for direct logging control in _suppress_provider_logs
1414
import os
15+
import secrets
1516
import shutil
1617
import subprocess
1718
import sys
@@ -216,6 +217,12 @@ def add_letsgo_arguments(parser: argparse.ArgumentParser) -> None:
216217
default="127.0.0.1",
217218
help="Host to bind the server to",
218219
)
220+
parser.add_argument(
221+
"--no-auth",
222+
action="store_true",
223+
default=False,
224+
help="Disable authentication entirely (generates no server.auth block in config).",
225+
)
219226

220227

221228
def _add_file_search_and_responses(run_config: StackConfig) -> None:
@@ -479,6 +486,21 @@ async def _run_letsgo_cmd_impl(args: argparse.Namespace, parser: argparse.Argume
479486

480487
config_dict["server"]["host"] = args.host
481488

489+
if not args.no_auth:
490+
api_keys = [f"ogk_{secrets.token_urlsafe(24)}" for _ in range(3)]
491+
if "server" not in config_dict:
492+
config_dict["server"] = {}
493+
config_dict["server"]["auth"] = {
494+
"provider_config": {"type": "local_api_key", "api_keys": api_keys},
495+
}
496+
cprint(" ✓ Simple authentication enabled", color="green")
497+
cprint(" Here are keys you can use for authentication:", color="green")
498+
for key in api_keys:
499+
cprint(f" {key}", color="yellow")
500+
cprint(f' curl -k -H "Authorization: Bearer {api_keys[0]}" \\', color="cyan")
501+
cprint(f" https://localhost:{args.port}/v1/chat/completions", color="cyan")
502+
cprint("", color="green")
503+
482504
config_file = distro_dir / "config.yaml"
483505
logger.info("Writing generated config to", config_file=config_file)
484506
with open(config_file, "w") as f:

src/ogx/core/datatypes.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ class OAuth2IntrospectionConfig(BaseModel):
202202
class AuthProviderType(StrEnum):
203203
"""Supported authentication provider types."""
204204

205+
LOCAL_API_KEY = "local_api_key"
205206
OAUTH2_TOKEN = "oauth2_token"
206207
GITHUB_TOKEN = "github_token"
207208
CUSTOM = "custom"
@@ -303,6 +304,15 @@ class CustomAuthConfig(BaseModel):
303304
)
304305

305306

307+
class LocalApiKeyAuthConfig(BaseModel):
308+
"""Simple API key authentication for single-key deployments."""
309+
310+
type: Literal[AuthProviderType.LOCAL_API_KEY] = AuthProviderType.LOCAL_API_KEY
311+
api_keys: list[str] = Field(
312+
description="API keys that clients can send via the Authorization: Bearer header.",
313+
)
314+
315+
306316
class GitHubTokenAuthConfig(BaseModel):
307317
"""Configuration for GitHub token authentication."""
308318

@@ -394,6 +404,7 @@ class UpstreamHeaderAuthConfig(BaseModel):
394404

395405
AuthProviderConfig = Annotated[
396406
OAuth2TokenAuthConfig
407+
| LocalApiKeyAuthConfig
397408
| GitHubTokenAuthConfig
398409
| CustomAuthConfig
399410
| KubernetesAuthProviderConfig

src/ogx/core/server/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ server/
99
__init__.py
1010
server.py # Main FastAPI app, route dispatch, SSE streaming, lifespan
1111
auth.py # AuthenticationMiddleware (Bearer token validation)
12-
auth_providers.py # Auth provider implementations (Kubernetes, custom endpoint)
12+
auth_providers.py # Auth provider implementations (Kubernetes, custom endpoint, local API key)
1313
metrics.py # RequestMetricsMiddleware (per-API request metrics)
1414
routes.py # Route initialization and matching from FastAPI routers
1515
fastapi_router_registry.py # Auto-discovery of FastAPI routers from ogx_api packages
@@ -30,7 +30,7 @@ Routes are defined as native FastAPI routers. `fastapi_router_registry.py` auto-
3030
### Middleware
3131

3232
- **`RequestMetricsMiddleware`** (`metrics.py`): Tracks per-API request counts and latency metrics. Runs as the outermost middleware.
33-
- **`AuthenticationMiddleware`** (`auth.py`): Validates Bearer tokens using a configured auth provider (Kubernetes, custom endpoint). Extracts user identity, attributes, and `tenant_id` for access control. Each auth provider resolves `tenant_id` from its source (JWT claim, HTTP header, K8s claim, or custom endpoint field). Endpoints can opt out by setting `openapi_extra={PUBLIC_ROUTE_KEY: True}` on their route.
33+
- **`AuthenticationMiddleware`** (`auth.py`): Validates Bearer tokens using a configured auth provider (Kubernetes, custom endpoint, local API key). Extracts user identity, attributes, and `tenant_id` for access control. Each auth provider resolves `tenant_id` from its source (JWT claim, HTTP header, K8s claim, or custom endpoint field). The local API key provider returns attributes (`roles`, `teams`) but **does not** resolve `tenant_id` — it only supports `single` or `disabled` tenancy modes. Endpoints can opt out by setting `openapi_extra={PUBLIC_ROUTE_KEY: True}` on their route.
3434
- **`TenancyMiddleware`** (`auth.py`): Enforces the configured tenancy mode after authentication. In `disabled` mode: passthrough. In `single` mode: overrides `tenant_id` to the configured default (works with or without auth). In `multi` mode: rejects requests with no `tenant_id` (401).
3535
- **`RouteAuthorizationMiddleware`** (`auth.py`): Enforces route-level access policies based on user roles.
3636
- **`ClientVersionMiddleware`** (`server.py`): Rejects requests from clients with incompatible major.minor versions.

src/ogx/core/server/auth_providers.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
CustomAuthConfig,
2323
GitHubTokenAuthConfig,
2424
KubernetesAuthProviderConfig,
25+
LocalApiKeyAuthConfig,
2526
OAuth2TokenAuthConfig,
2627
UpstreamHeaderAuthConfig,
2728
User,
@@ -117,6 +118,32 @@ def get_auth_error_message(self, scope: Scope | None = None) -> str:
117118
return "Authentication required"
118119

119120

121+
class LocalApiKeyAuthProvider(AuthProvider):
122+
"""Validates requests against a configured set of API keys.
123+
124+
Any valid key is treated as both an admin and an owner, granting full
125+
access to all resources. The resolved user has ``roles=admin,owner`` and
126+
``teams=<key>`` so the default access-policy rules (``user in owners`` /
127+
``user is owner``) work as expected. This provider does not resolve a
128+
tenant ID, so it is incompatible with multi-tenant mode.
129+
"""
130+
131+
def __init__(self, config: LocalApiKeyAuthConfig) -> None:
132+
self.config = config
133+
self._valid_keys: set[str] = set(config.api_keys)
134+
135+
async def validate_token(self, token: str, scope: Scope | None = None) -> User:
136+
if token not in self._valid_keys:
137+
raise TokenValidationError("Invalid or missing API key")
138+
return User(
139+
principal=token,
140+
attributes={"roles": ["admin", "owner"], "teams": [token]},
141+
)
142+
143+
async def close(self) -> None:
144+
pass
145+
146+
120147
def get_attributes_from_claims(claims: dict[str, Any], mapping: dict[str, str]) -> dict[str, list[str]]:
121148
"""Extract user attributes from token claims using the configured claims-to-attributes mapping.
122149
@@ -732,6 +759,8 @@ def create_auth_provider(config: AuthenticationConfig) -> AuthProvider:
732759
"""Factory function to create the appropriate auth provider."""
733760
provider_config = config.provider_config
734761

762+
if isinstance(provider_config, LocalApiKeyAuthConfig):
763+
return LocalApiKeyAuthProvider(provider_config)
735764
if isinstance(provider_config, CustomAuthConfig):
736765
return CustomAuthProvider(provider_config)
737766
elif isinstance(provider_config, OAuth2TokenAuthConfig):

src/ogx/core/server/server.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from ogx.core.access_control.access_control import AccessDeniedError
2929
from ogx.core.datatypes import (
3030
AuthenticationRequiredError,
31+
LocalApiKeyAuthConfig,
3132
StackConfig,
3233
TenancyMode,
3334
)
@@ -309,6 +310,19 @@ def validate_auth_security(config: StackConfig) -> None:
309310
if not config.server.auth:
310311
return
311312
provider_config = config.server.auth.provider_config
313+
314+
# Hard error: local_api_key doesn't resolve tenant IDs, so multi-tenancy
315+
# is incompatible. The admin will get runtime 401s with no explanation.
316+
tenancy_mode = config.server.tenancy.mode
317+
if isinstance(provider_config, LocalApiKeyAuthConfig) and tenancy_mode == TenancyMode.MULTI:
318+
raise SystemExit(
319+
"server.auth.provider_config.type is 'local_api_key' but "
320+
"server.tenancy.mode is 'multi'. The local_api_key provider does "
321+
"not resolve tenant IDs. Use tenancy mode 'single' or 'disabled' "
322+
"instead, or switch to an auth provider that resolves tenant_ids "
323+
"(oauth2_token, kubernetes, upstream_header, custom)."
324+
)
325+
312326
if not provider_config or not hasattr(provider_config, "verify_tls") or provider_config.verify_tls:
313327
return
314328

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# Copyright (c) The OGX Contributors.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the terms described in the LICENSE file in
5+
# the root directory of this source tree.
6+
7+
import pytest
8+
from fastapi import FastAPI
9+
from fastapi.testclient import TestClient
10+
11+
from ogx.core.datatypes import (
12+
AuthenticationConfig,
13+
LocalApiKeyAuthConfig,
14+
StackConfig,
15+
TenancyConfig,
16+
)
17+
from ogx.core.server.auth import AuthenticationMiddleware
18+
from ogx.core.server.auth_providers import LocalApiKeyAuthProvider, TokenValidationError
19+
from ogx.core.server.server import validate_auth_security
20+
21+
KEY1 = "ogk_abc123"
22+
KEY2 = "ogk_def456"
23+
INVALID_KEY = "ogk_invalid"
24+
25+
_CONFIG = LocalApiKeyAuthConfig(api_keys=[KEY1, KEY2])
26+
27+
28+
async def test_valid_token_returns_user_with_attributes():
29+
provider = LocalApiKeyAuthProvider(_CONFIG)
30+
user = await provider.validate_token(KEY1)
31+
assert user.principal == KEY1
32+
assert user.attributes == {"roles": ["admin", "owner"], "teams": [KEY1]}
33+
34+
35+
async def test_invalid_token_raises():
36+
provider = LocalApiKeyAuthProvider(_CONFIG)
37+
with pytest.raises(TokenValidationError, match="Invalid or missing API key"):
38+
await provider.validate_token(INVALID_KEY)
39+
40+
41+
async def test_all_keys_work():
42+
provider = LocalApiKeyAuthProvider(_CONFIG)
43+
u1 = await provider.validate_token(KEY1)
44+
u2 = await provider.validate_token(KEY2)
45+
assert u1.attributes["roles"] == ["admin", "owner"]
46+
assert u2.attributes["roles"] == ["admin", "owner"]
47+
48+
49+
async def test_attributes_are_immutable():
50+
provider = LocalApiKeyAuthProvider(_CONFIG)
51+
user = await provider.validate_token(KEY1)
52+
# mutating the returned dict should not affect future validations
53+
user.attributes.pop("roles")
54+
user2 = await provider.validate_token(KEY1)
55+
assert user2.attributes == {"roles": ["admin", "owner"], "teams": [KEY1]}
56+
57+
58+
# --- Authentication middleware integration tests ---
59+
60+
61+
@pytest.fixture
62+
def local_api_key_app():
63+
app = FastAPI()
64+
65+
auth_config = AuthenticationConfig(
66+
provider_config=LocalApiKeyAuthConfig(
67+
type="local_api_key",
68+
api_keys=["test-api-key-12345", "secondary-key-67890", "third-key-abcde"],
69+
),
70+
)
71+
72+
app.add_middleware(
73+
AuthenticationMiddleware,
74+
auth_config=auth_config,
75+
)
76+
77+
@app.get("/test")
78+
def test_endpoint():
79+
return {"message": "Authentication successful"}
80+
81+
return app
82+
83+
84+
@pytest.fixture
85+
def local_api_key_client(local_api_key_app):
86+
return TestClient(local_api_key_app)
87+
88+
89+
def test_authenticated_endpoint_without_token(local_api_key_client):
90+
"""Test accessing protected endpoint without token"""
91+
response = local_api_key_client.get("/test")
92+
assert response.status_code == 401
93+
assert "Authentication required" in response.json()["error"]["message"]
94+
95+
96+
def test_authenticated_endpoint_with_invalid_bearer_format(local_api_key_client):
97+
"""Test accessing protected endpoint with invalid bearer format"""
98+
response = local_api_key_client.get("/test", headers={"Authorization": "InvalidFormat token123"})
99+
assert response.status_code == 401
100+
assert "Invalid Authorization header format" in response.json()["error"]["message"]
101+
102+
103+
def test_authenticated_endpoint_with_invalid_api_key(local_api_key_client):
104+
"""Test accessing protected endpoint with wrong API key"""
105+
response = local_api_key_client.get("/test", headers={"Authorization": "Bearer wrong-key"})
106+
assert response.status_code == 401
107+
assert "Invalid or missing API key" in response.json()["error"]["message"]
108+
109+
110+
def test_authenticated_endpoint_with_valid_api_key(local_api_key_client):
111+
"""Test accessing protected endpoint with correct API key"""
112+
response = local_api_key_client.get(
113+
"/test",
114+
headers={"Authorization": "Bearer test-api-key-12345"},
115+
)
116+
assert response.status_code == 200
117+
assert response.json()["message"] == "Authentication successful"
118+
119+
120+
def test_authenticated_endpoint_with_valid_api_key_secondary(local_api_key_client):
121+
"""Test accessing protected endpoint with secondary API key"""
122+
response = local_api_key_client.get(
123+
"/test",
124+
headers={"Authorization": "Bearer secondary-key-67890"},
125+
)
126+
assert response.status_code == 200
127+
assert response.json()["message"] == "Authentication successful"
128+
129+
130+
def test_authenticated_endpoint_empty_bearer_token(local_api_key_client):
131+
"""Test accessing protected endpoint with empty bearer token"""
132+
response = local_api_key_client.get(
133+
"/test",
134+
headers={"Authorization": "Bearer "},
135+
)
136+
assert response.status_code == 401
137+
assert "Invalid or missing API key" in response.json()["error"]["message"]
138+
139+
140+
# --- Startup validation ---
141+
142+
143+
class TestLocalApiKeyTenancyValidation:
144+
def _make_config(self, tenancy_mode, default_tenant_id=None):
145+
return StackConfig(
146+
version=2,
147+
distro_name="test",
148+
providers={},
149+
server={
150+
"insecure": True,
151+
"auth": AuthenticationConfig(
152+
provider_config=LocalApiKeyAuthConfig(
153+
api_keys=["ogk_test123"],
154+
),
155+
),
156+
"tenancy": TenancyConfig(mode=tenancy_mode, default_tenant_id=default_tenant_id),
157+
},
158+
)
159+
160+
def test_multi_tenancy_errors(self):
161+
config = self._make_config("multi")
162+
with pytest.raises(SystemExit, match="local_api_key.*multi"):
163+
validate_auth_security(config)
164+
165+
def test_single_tenancy_passes(self):
166+
config = self._make_config("single", default_tenant_id="acme-corp")
167+
validate_auth_security(config)
168+
169+
def test_disabled_tenancy_passes(self):
170+
config = self._make_config("disabled")
171+
validate_auth_security(config)

0 commit comments

Comments
 (0)