diff --git a/apps/vedana/Makefile b/apps/vedana/Makefile index cef87ef9..3c8ff6ac 100644 --- a/apps/vedana/Makefile +++ b/apps/vedana/Makefile @@ -3,7 +3,7 @@ BASE_VERSION=$(shell uv version --short | sed 's/[\+\/]/-/g') BRANCH=$(shell git rev-parse --abbrev-ref HEAD | sed 's/[\+\/]/-/g') GIT_HASH=$(shell git rev-parse --short HEAD) -REPO=ghcr.io/epoch8/ai-assistants-oss/vedana +REPO=ghcr.io/epoch8/vedana/vedana ifeq ($(BRANCH),master) VERSION=$(BASE_VERSION) @@ -18,3 +18,6 @@ build: docker-run-it: docker run --rm -it ${IMAGE} bash + +upload: + docker push ${IMAGE} \ No newline at end of file diff --git a/libs/jims-api/README.md b/libs/jims-api/README.md index fe393058..8b9cd1b5 100644 --- a/libs/jims-api/README.md +++ b/libs/jims-api/README.md @@ -14,11 +14,24 @@ Environment variables: - `JIMS_APP` - JIMS app import path (`module:attr`) - `JIMS_PORT` - HTTP port - `JIMS_HOST` - HTTP host -- `JIMS_API_KEY` - optional bearer token for auth +- `JIMS_API_KEY` - optional static bearer token for auth +- `JIMS_AUTHENTIK_URL` - Authentik instance base URL (e.g. `https://authentik.epoch8.dev`) +- `JIMS_AUTHENTIK_APP_SLUG` - Authentik application slug to check access against + +## Authentication + +Auth is checked in the following order: + +1. If `JIMS_API_KEY` is set and the provided bearer token matches — allow +2. If `JIMS_AUTHENTIK_URL` and `JIMS_AUTHENTIK_APP_SLUG` are set — validate the bearer token against Authentik's `/api/v3/core/applications/{slug}/check_access/` endpoint. If 200 — allow +3. If no auth is configured (neither static key nor Authentik) — open access +4. Otherwise — 401 Unauthorized + +Send the token as `Authorization: Bearer `. ## Endpoints -- `GET /health` +- `GET /healthz` - `POST /api/v1/chat` ### `POST /api/v1/chat` @@ -52,4 +65,4 @@ Response: } ``` -If `JIMS_API_KEY` is set, send it as `Authorization: Bearer ` +Send the API token as `Authorization: Bearer ` (see [Authentication](#authentication) above). diff --git a/libs/jims-api/pyproject.toml b/libs/jims-api/pyproject.toml index be7850b8..17807c80 100644 --- a/libs/jims-api/pyproject.toml +++ b/libs/jims-api/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "jims-core>=0.5.1", "loguru>=0.7.3", "uvicorn>=0.35.0", + "httpx>=0.28.0", ] [tool.uv.sources] diff --git a/libs/jims-api/src/jims_api/main.py b/libs/jims-api/src/jims_api/main.py index 18278338..21ced7fc 100644 --- a/libs/jims-api/src/jims_api/main.py +++ b/libs/jims-api/src/jims_api/main.py @@ -3,6 +3,7 @@ from uuid import UUID import click +import httpx import uvicorn from fastapi import Depends, FastAPI, Header, HTTPException from jims_core.app import JimsApp @@ -49,14 +50,24 @@ def _extract_token(authorization: str | None) -> str | None: return authorization -def _auth_dependency(api_key: str | None): +def _auth_dependency(api_key: str | None, authentik_url: str | None, authentik_app_slug: str | None): async def require_auth(authorization: str | None = Header(default=None, alias="Authorization")) -> None: - if api_key is None: + if api_key is None and authentik_url is None: return token = _extract_token(authorization) - if token != api_key: - raise HTTPException(status_code=401, detail="Unauthorized") + + if api_key is not None and token == api_key: + return + + if authentik_url is not None and authentik_app_slug is not None and token is not None: + url = f"{authentik_url}/api/v3/core/applications/{authentik_app_slug}/check_access/" + async with httpx.AsyncClient() as client: + resp = await client.get(url, headers={"Authorization": f"Bearer {token}"}) + if resp.status_code == 200 and resp.json().get("passing") is True: + return + + raise HTTPException(status_code=401, detail="Unauthorized") return require_auth @@ -68,9 +79,11 @@ async def _resolve_jims_app(app_name: str) -> JimsApp: return loaded_app -def create_api(jims_app: JimsApp, api_key: str | None) -> FastAPI: +def create_api( + jims_app: JimsApp, api_key: str | None, authentik_url: str | None = None, authentik_app_slug: str | None = None +) -> FastAPI: app = FastAPI(title="JIMS API", version="0.1.0") - require_auth = _auth_dependency(api_key) + require_auth = _auth_dependency(api_key, authentik_url, authentik_app_slug) @app.get("/healthz") async def health() -> dict[str, str]: @@ -141,6 +154,8 @@ async def chat(req: ChatRequest) -> ChatResponse: @click.option("--host", type=click.STRING, default="0.0.0.0") @click.option("--port", type=click.INT, default=8080) @click.option("--api-key", type=click.STRING, default=None, help="Optional bearer token for API auth.") +@click.option("--authentik-url", type=click.STRING, default=None, help="Authentik instance base URL.") +@click.option("--authentik-app-slug", type=click.STRING, default=None, help="Authentik application slug.") @click.option("--enable-sentry", is_flag=True, help="Enable tracing to Sentry", default=False) @click.option("--metrics-port", type=click.INT, default=8000) @click.option("--verbose", is_flag=True, default=False) @@ -149,6 +164,8 @@ def cli( host: str, port: int, api_key: str | None, + authentik_url: str | None, + authentik_app_slug: str | None, enable_sentry: bool, metrics_port: int, verbose: bool, @@ -163,7 +180,7 @@ def cli( async def run_api() -> None: jims_app = await _resolve_jims_app(app) - api = create_api(jims_app, api_key=api_key) + api = create_api(jims_app, api_key=api_key, authentik_url=authentik_url, authentik_app_slug=authentik_app_slug) server = uvicorn.Server(uvicorn.Config(api, host=host, port=port, log_level="info")) await server.serve()