diff --git a/.env.example b/.env.example index 4ed7d0a..2c0a78d 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ ENV=prod PUBLIC_BASE_URL=https://iplvr.it.ntnu.no +CORS_ALLOWED_ORIGINS=http://localhost:3001,http://localhost:4001 # TLS files on the Docker host. Override these if the server stores certs elsewhere. SSL_CERT_PATH=/root/iplvr.it.ntnu.no.crt @@ -29,6 +30,7 @@ KEYCLOAK_CLIENT_ID=ragdoll-config KEYCLOAK_CLIENT_SECRET=replace-with-keycloak-client-secret KEYCLOAK_VERIFY_AUDIENCE=false KEYCLOAK_ISSUER=https://iplvr.it.ntnu.no/realms/ragdoll +KEYCLOAK_ALLOWED_ISSUERS= KEYCLOAK_JWKS_URL=https://iplvr.it.ntnu.no/realms/ragdoll/protocol/openid-connect/certs NEXTAUTH_SECRET=replace-with-a-long-random-secret JWT_SECRET=replace-with-a-long-random-secret diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 2f973be..34aab9a 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -92,11 +92,12 @@ services: - MONGODB_URI=mongodb://admin:changeme@mongodb:27017/ragdoll_dev?authSource=admin - MONGODB_DATABASE=ragdoll_dev - KEYCLOAK_BASE_URL=http://keycloak:8080 - - KEYCLOAK_REALM=${KEYCLOAK_REALM:-ragdoll} - - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-ragdoll-config} - - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-ragdoll-config-secret} - - KEYCLOAK_VERIFY_AUDIENCE=${KEYCLOAK_VERIFY_AUDIENCE:-false} - - KEYCLOAK_ISSUER=${KEYCLOAK_ISSUER:-http://localhost:8080/realms/ragdoll} + - KEYCLOAK_REALM=ragdoll + - KEYCLOAK_CLIENT_ID=ragdoll-config + - KEYCLOAK_CLIENT_SECRET=ragdoll-config-secret + - KEYCLOAK_VERIFY_AUDIENCE=false + - KEYCLOAK_ISSUER=http://localhost:8080/realms/ragdoll + - KEYCLOAK_ALLOWED_ISSUERS=http://localhost:8080/realms/ragdoll,http://localhost/realms/ragdoll - KEYCLOAK_JWKS_URL=http://keycloak:8080/realms/ragdoll/protocol/openid-connect/certs - AUTH_BOOTSTRAP_ENABLED=${AUTH_BOOTSTRAP_ENABLED:-true} networks: diff --git a/docker-compose.yml b/docker-compose.yml index 2538e2d..aa66e82 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -90,6 +90,7 @@ services: - ENV=prod - RAGDOLL_CONFIG_API_URL=${PUBLIC_BASE_URL:-https://iplvr.it.ntnu.no} - RAGDOLL_CHAT_API_URL=${PUBLIC_BASE_URL:-https://iplvr.it.ntnu.no} + - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-http://localhost:3001,http://localhost:4001} - MONGODB_URI=${MONGODB_URI:?Set MONGODB_URI in RAGdoll/.env. URL-encode special characters in the username/password.} - MONGODB_DATABASE=${MONGODB_DATABASE:-ragdoll_prod} - MONGODB_CONTEXT_COLLECTION=${MONGODB_CONTEXT_COLLECTION:-context} diff --git a/src/access_service/access_service.py b/src/access_service/access_service.py index 1ca22f2..4948700 100644 --- a/src/access_service/access_service.py +++ b/src/access_service/access_service.py @@ -35,7 +35,11 @@ def get_unique_access_key_id(self, agent: Agent): raise ValueError("Could not generate a new key!") def generate_accesskey( - self, name: str, expiry_date: datetime | None, agent_id: str + self, + name: str, + expiry_date: datetime | None, + agent_id: str, + view_once: bool = True, ) -> AccessKey: if (expiry_date is not None) and expiry_date < datetime.now(): raise ValueError("Expiry date cannot be in the past") @@ -50,6 +54,7 @@ def generate_accesskey( expiry_date=expiry_date, created=datetime.now(), last_use=None, + view_once=view_once, ) agent.access_key.append(access_key) self.agent_database.add_agent(agent) diff --git a/src/access_service/base.py b/src/access_service/base.py index 2399fba..2b3db9a 100644 --- a/src/access_service/base.py +++ b/src/access_service/base.py @@ -7,7 +7,7 @@ class AbstractAccessService: @abstractmethod def generate_accesskey( - self, name: str, expiry_date: datetime, agent_id: str + self, name: str, expiry_date: datetime | None, agent_id: str, view_once: bool = True ) -> AccessKey: """Generates an accesskey and adds it into the database, returns the generated key.""" diff --git a/src/auth/auth_provider/factory.py b/src/auth/auth_provider/factory.py index 2f6b855..ad09253 100644 --- a/src/auth/auth_provider/factory.py +++ b/src/auth/auth_provider/factory.py @@ -20,6 +20,7 @@ def auth_provider_factory(provider: str, user_db: UserDao) -> AuthProvider: case "keycloak": return KeycloakAuthProvider( issuer=config.KEYCLOAK_ISSUER, + allowed_issuers=config.KEYCLOAK_ALLOWED_ISSUERS, jwks_url=config.KEYCLOAK_JWKS_URL, client_id=config.KEYCLOAK_CLIENT_ID, verify_audience=config.KEYCLOAK_VERIFY_AUDIENCE, diff --git a/src/auth/auth_provider/keycloak_auth_provider.py b/src/auth/auth_provider/keycloak_auth_provider.py index 0168eaa..284ac13 100644 --- a/src/auth/auth_provider/keycloak_auth_provider.py +++ b/src/auth/auth_provider/keycloak_auth_provider.py @@ -6,6 +6,7 @@ import jwt import requests +from jwt import InvalidIssuerError from cryptography.hazmat.primitives.asymmetric import rsa from src.auth.auth_provider.base import AuthProvider @@ -28,12 +29,17 @@ class KeycloakAuthProvider(AuthProvider): def __init__( self, issuer: str, + allowed_issuers: list[str] | None, jwks_url: str, client_id: str, verify_audience: bool, user_db: UserDao, ): self.issuer = issuer.rstrip("/") + self.allowed_issuers = { + issuer.rstrip("/"), + *(item.rstrip("/") for item in (allowed_issuers or [])), + } self.jwks_url = jwks_url self.client_id = client_id self.verify_audience = verify_audience @@ -85,7 +91,6 @@ def _decode_token(self, token: str) -> dict[str, Any]: decode_kwargs: dict[str, Any] = { "key": public_key, "algorithms": ["RS256"], - "issuer": self.issuer, } if self.verify_audience: decode_kwargs["audience"] = self.client_id @@ -93,6 +98,14 @@ def _decode_token(self, token: str) -> dict[str, Any]: decode_kwargs["options"] = {"verify_aud": False} claims = jwt.decode(token, **decode_kwargs) + token_issuer = str(claims.get("iss", "")).rstrip("/") + if token_issuer not in self.allowed_issuers: + logger.warning( + "Invalid Keycloak issuer. Expected one of %s, got '%s'", + sorted(self.allowed_issuers), + token_issuer, + ) + raise InvalidIssuerError("Invalid issuer") if claims.get("azp") and claims["azp"] != self.client_id: logger.debug( "Keycloak token authorized party '%s' does not match configured client '%s'", diff --git a/src/config.py b/src/config.py index 9954baa..dbd3011 100644 --- a/src/config.py +++ b/src/config.py @@ -41,6 +41,11 @@ def __init__(self): self.RAGDOLL_CHAT_API_URL = os.getenv( "RAGDOLL_CHAT_API_URL", "http://localhost:3001" ) + self.CORS_ALLOWED_ORIGINS = [ + origin.strip().rstrip("/") + for origin in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") + if origin.strip() + ] self.MODEL = os.getenv("MODEL", "idun") self.GPT_MODEL = os.getenv("GPT_MODEL", "gpt-4o-mini") @@ -96,6 +101,11 @@ def __init__(self): "KEYCLOAK_ISSUER", f"{self.KEYCLOAK_BASE_URL}/realms/{self.KEYCLOAK_REALM}", ).rstrip("/") + self.KEYCLOAK_ALLOWED_ISSUERS = [ + issuer.strip().rstrip("/") + for issuer in os.getenv("KEYCLOAK_ALLOWED_ISSUERS", "").split(",") + if issuer.strip() + ] self.KEYCLOAK_JWKS_URL = os.getenv( "KEYCLOAK_JWKS_URL", f"{self.KEYCLOAK_ISSUER}/protocol/openid-connect/certs", diff --git a/src/main.py b/src/main.py index 98bf25f..25ff658 100644 --- a/src/main.py +++ b/src/main.py @@ -25,6 +25,7 @@ dictConfig(LOGGING_CONFIG) logger = logging.getLogger(__name__) +config = Config() app = FastAPI( title="Chat-Service Microservice API", @@ -37,9 +38,10 @@ app.add_middleware( CORSMiddleware, allow_origins=[ - Config().RAGDOLL_CONFIG_API_URL, - Config().RAGDOLL_CHAT_API_URL, - ], # TODO: Frontend URL(s), static rn for testing, but need env variable later + config.RAGDOLL_CONFIG_API_URL, + config.RAGDOLL_CHAT_API_URL, + *config.CORS_ALLOWED_ORIGINS, + ], allow_credentials=True, allow_methods=["*"], # Allow GET, POST, PUT, DELETE, etc. allow_headers=["*"], # Allow all headers diff --git a/src/models/accesskey.py b/src/models/accesskey.py index 8dea408..9f4b241 100644 --- a/src/models/accesskey.py +++ b/src/models/accesskey.py @@ -15,3 +15,4 @@ class AccessKey(BaseModel): expiry_date: datetime | None created: datetime | None last_use: datetime | None + view_once: bool = True diff --git a/src/routes/agents.py b/src/routes/agents.py index 48815a8..ee87e0e 100644 --- a/src/routes/agents.py +++ b/src/routes/agents.py @@ -1,4 +1,5 @@ import os +import logging from datetime import datetime, timedelta from typing import Annotated @@ -10,7 +11,7 @@ from src.globals import access_service, agent_dao, auth_service, user_dao from src.llm import list_llm_models from src.models.accesskey import AccessKey -from src.models.agent import Agent +from src.models.agent import Agent, Role from src.models.errors.embedding_error import EmbeddingAPIError, EmbeddingError from src.models.errors.llm_error import LLMAPIError from src.models.model import Model @@ -20,6 +21,7 @@ config = Config() router = APIRouter() +logger = logging.getLogger(__name__) def _auth_disabled() -> bool: @@ -136,6 +138,25 @@ class ProviderKeyRequest(BaseModel): api_key: str +class ExternalAgentInfo(BaseModel): + agent_id: str + name: str + roles: list[Role] + + +def _access_key_valid_for_agent(agent: Agent, access_key: str | None) -> bool: + normalized_key = access_key.strip() if access_key else None + if not normalized_key: + return False + + now = datetime.now() + for stored_key in agent.access_key: + if stored_key.key != normalized_key: + continue + return stored_key.expiry_date is None or now < stored_key.expiry_date + return False + + def _map_embedding_api_error(error: EmbeddingAPIError) -> int: message = str(error.original_error).lower() if error.original_error else "" if any(keyword in message for keyword in ("quota", "rate limit", "429")): @@ -426,6 +447,54 @@ def agent_info( return agent +@router.get("/agent-info-by-accesskey", response_model=ExternalAgentInfo) +def agent_info_by_access_key( + access_key: Annotated[str | None, Header()], +): + """Resolve safe agent metadata from an external access key. + + This endpoint is intended for unauthenticated external clients. The access + key itself is the authorization credential, so the response intentionally + excludes model configuration, provider API keys, and access-key records. + """ + agents = agent_dao.get_agents() + total_keys = 0 + active_keys = 0 + expired_keys = 0 + has_header = bool(access_key and access_key.strip()) + now = datetime.now() + + for agent in agents: + total_keys += len(agent.access_key) + for stored_key in agent.access_key: + if stored_key.expiry_date is None or now < stored_key.expiry_date: + active_keys += 1 + else: + expired_keys += 1 + if _access_key_valid_for_agent(agent, access_key): + return ExternalAgentInfo( + agent_id=agent.id or "", + name=agent.name, + roles=agent.roles, + ) + + logger.warning( + "External access-key lookup failed. Header present: %s, agents scanned: %s, keys scanned: %s, active keys: %s, expired keys: %s", + has_header, + len(agents), + total_keys, + active_keys, + expired_keys, + ) + raise HTTPException( + status_code=401, + detail=( + "Access key not valid. Ensure you are using the full key value shown " + "when the key was created, not the key name or key id." + ), + ) + + # TODO : implement a better system of returning status codes on exceptions @@ -434,18 +503,19 @@ def new_access_key( name: str, agent_id: str, expiry_date: str | None = None, + view_once: bool = True, authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None, ): _ensure_agent_access(authorize, agent_id) try: if expiry_date is None: - return access_service.generate_accesskey(name, None, agent_id) + return access_service.generate_accesskey(name, None, agent_id, view_once) else: expiry_date_formatted = datetime.fromisoformat(expiry_date).replace( tzinfo=None ) return access_service.generate_accesskey( - name, expiry_date_formatted, agent_id + name, expiry_date_formatted, agent_id, view_once ) except HTTPException: @@ -478,9 +548,10 @@ def get_access_keys( raise HTTPException( status_code=404, detail=f" agent of id not found {agent_id}" ) - access_keys = agent.access_key + access_keys = [access_key.model_copy() for access_key in agent.access_key] for access_key in access_keys: - access_key.key = None + if access_key.view_once: + access_key.key = None return access_keys