Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
30cf83b
chore: update deployment image tags [skip ci]
invalid-email-address Aug 6, 2026
ec4be72
fix backend startup failure on prod PVC
devajipatil Aug 6, 2026
bdf9a1f
chore: update deployment image tags [skip ci]
invalid-email-address Aug 6, 2026
111fd0e
load keycloak config at runtime instead of baked placeholders
devajipatil Aug 6, 2026
3f18871
chore: update deployment image tags [skip ci]
invalid-email-address Aug 6, 2026
369864d
Merge branch 'feature/keycloak-bpn' of github.com:ARENA2036/edc-manag…
devajipatil Aug 6, 2026
dbb3acd
feature: bpn from keycloak
devajipatil Aug 11, 2026
9dabf36
feature: bpn from keycloak
devajipatil Aug 11, 2026
ed4ece7
chore: update deployment image tags [skip ci]
invalid-email-address Aug 11, 2026
d844572
chore: cleanup comments
devajipatil Aug 11, 2026
863402e
chore: update deployment image tags [skip ci]
invalid-email-address Aug 12, 2026
dda2bfe
fix for pull request Unused import
devajipatil Aug 12, 2026
5b719e6
chore: pull PR-34
devajipatil Aug 18, 2026
8daf94d
chore: Charts version bump
devajipatil Aug 18, 2026
4db5f35
chore: Charts version bump
devajipatil Aug 18, 2026
6565a76
chore: 0f1faa undo changes
devajipatil Aug 19, 2026
9edd667
chore: scope PR to the Keycloak BPN feature
devajipatil Aug 19, 2026
fc3056a
chore: copilot PR comments resolve
devajipatil Aug 19, 2026
c860b01
chore: update deployment image tags [skip ci]
invalid-email-address Aug 19, 2026
c2af2d5
fix: validate caller-supplied URLs before outbound requests (SSRF)
devajipatil Aug 19, 2026
6a75ed0
Merge branch 'feature/keycloak-bpn' of github.com:ARENA2036/edc-manag…
devajipatil Aug 19, 2026
0d6771b
chore: update deployment image tags [skip ci]
invalid-email-address Aug 19, 2026
b586f20
chore: correct header mistakes
devajipatil Aug 19, 2026
69514e6
Merge branch 'feature/keycloak-bpn' of github.com:ARENA2036/edc-manag…
devajipatil Aug 19, 2026
3b20658
chore: update deployment image tags [skip ci]
invalid-email-address Aug 20, 2026
833cf59
fix: use Recreate rollout so the backend's RWO volume can transfer
devajipatil Aug 20, 2026
3414842
fix: rebuild outbound probe URL from vetted components (partial SSRF)
devajipatil Aug 20, 2026
b1acab5
chore: update deployment image tags [skip ci]
invalid-email-address Aug 20, 2026
3267fd6
fix: set fsGroup so the backend can write sqlite on its PVC
devajipatil Aug 20, 2026
03b71ed
chore: test backend deployment
devajipatil Aug 20, 2026
601f776
Merge branch 'feature/backend-rollout-strategy' of github.com:ARENA20…
devajipatil Aug 20, 2026
ee9ab56
chore: update deployment image tags [skip ci]
invalid-email-address Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,6 @@
###############################################################
---
version: 2
registries:
github-central-pipelines:
type: git
url: https://github.com
username: x-access-token
password: ${{ secrets.CENTRAL_PIPELINES_READ_ONLY_GH_TOKEN }}

updates:
# Github Actions
Expand Down
239 changes: 192 additions & 47 deletions backend/auth/keycloak_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,63 +19,208 @@
#
# SPDX-License-Identifier: Apache-2.0
###############################################################
import os
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
import logging
import os
import threading
import time
from typing import Any, Optional

import requests
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import jwt
from jose.exceptions import ExpiredSignatureError, JWTClaimsError, JWTError

logger = logging.getLogger('app')

security = HTTPBearer()
security = HTTPBearer(auto_error=False)

JWKS_TTL_SECONDS = 300
ALGORITHMS = ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256"]

BPN_CLAIM = "bpn"
COMPANY_CLAIM = "organisation"


def _clean(value: Optional[str]) -> str:
"""Trim a config value, treating un-substituted placeholders as unset."""
if not value or not isinstance(value, str):
return ""

stripped = value.strip()
if stripped.startswith("__") or (stripped.startswith("${") and stripped.endswith("}")):
return ""

return stripped


def _first(value: Any) -> str:
"""Flatten a claim to one string; Keycloak multivalued mappers emit lists."""
if isinstance(value, str):
return value.strip()

if isinstance(value, (list, tuple)):
for entry in value:
flattened = _first(entry)
if flattened:
return flattened

return ""


class KeycloakOpenID:
def __init__(self):
self.keycloak_url = os.getenv("KEYCLOAK_URL", "__KEYCLOAK_URL__")
self.realm = os.getenv("KEYCLOAK_REALM", "__KEYCLOAK_REALM__")
self.client_id = os.getenv("KEYCLOAK_CLIENT_ID", "__KEYCLOAK_CLIENT_ID__")

self.keycloak_url = _clean(os.getenv("KEYCLOAK_URL"))
self.realm = _clean(os.getenv("KEYCLOAK_REALM"))
self.verify_signature = _env_flag("KEYCLOAK_VERIFY_SIGNATURE", True)
self._jwks: Optional[dict] = None
self._jwks_fetched_at = 0.0
self._jwks_lock = threading.Lock()

def configure(self, *, url=None, realm=None, fallback_url=None, fallback_realm=None):
"""Apply configuration.yml settings. Environment variables win."""
if not self.keycloak_url:
self.keycloak_url = _clean(url) or _clean(fallback_url)
if not self.realm:
self.realm = _clean(realm) or _clean(fallback_realm)

logger.info("[Keycloak] Verifying tokens issued by %s", self.issuer or "<unconfigured>")

@property
def is_configured(self) -> bool:
return bool(self.keycloak_url and self.realm)

@property
def issuer(self) -> str:
"""``<base>/realms/<realm>`` — the ``iss`` value Keycloak puts in tokens."""
if not self.is_configured:
return ""

return f"{self.keycloak_url.rstrip('/')}/realms/{self.realm}"

@property
def jwks_uri(self) -> str:
return f"{self.issuer}/protocol/openid-connect/certs" if self.issuer else ""

def add_swagger_config(self, app):
"""Add Keycloak OAuth2 to Swagger UI"""
pass

return None

def _fetch_jwks(self) -> Optional[dict]:
try:
response = requests.get(self.jwks_uri, timeout=10)
response.raise_for_status()
jwks = response.json()
except Exception as exception:
logger.error("[Keycloak] Could not fetch JWKS from %s: %s", self.jwks_uri, exception)
return None

return jwks if isinstance(jwks, dict) and jwks.get("keys") else None

def get_jwks(self, force_refresh: bool = False) -> Optional[dict]:
with self._jwks_lock:
fresh = self._jwks and (time.monotonic() - self._jwks_fetched_at) < JWKS_TTL_SECONDS
if fresh and not force_refresh:
return self._jwks

jwks = self._fetch_jwks()
if jwks:
self._jwks = jwks
self._jwks_fetched_at = time.monotonic()
# Keep serving the previous keys on a failed refresh: a blip at the
# IdP should not log everyone out.
return self._jwks

def decode_token(self, token: str) -> dict:
"""Return the token's verified claims, or raise 401."""
if not self.verify_signature:
logger.warning("[Keycloak] KEYCLOAK_VERIFY_SIGNATURE is off; token is untrusted.")
return jwt.decode(token, key="", options={"verify_signature": False,
"verify_aud": False})

if not self.is_configured:
logger.error("[Keycloak] No identity provider configured; rejecting bearer tokens.")
raise _unauthorized("Identity provider is not configured; the token cannot be trusted.")

try:
kid = jwt.get_unverified_header(token).get("kid")
except JWTError as exception:
raise _unauthorized(f"Malformed token: {exception}")

jwks = self.get_jwks()
if not jwks or not any(key.get("kid") == kid for key in jwks.get("keys", [])):
jwks = self.get_jwks(force_refresh=True)
if not jwks:
raise _unauthorized("Identity provider keys are unavailable")

try:
return jwt.decode(token, key=jwks, algorithms=ALGORITHMS,
issuer=self.issuer, options={"verify_aud": False})
except ExpiredSignatureError:
raise _unauthorized("Token has expired")
except (JWTClaimsError, JWTError) as exception:
# Nearly always the wrong realm rather than a bad token, so name both
# issuers instead of just saying "signature verification failed".
raise _unauthorized(
f"Token rejected: {exception}. Token issuer={_unverified_issuer(token)!r}, "
f"configured issuer={self.issuer!r}."
)

def build_user(self, claims: dict, token: str = "") -> dict:
"""The caller and the company they act for, from verified claims."""
return {
"preferred_username": _first(claims.get("preferred_username")) or "unknown",
"name": _first(claims.get("name")),
"email": _first(claims.get("email")),
"bpn": _first(claims.get(BPN_CLAIM)).upper(),
"company": _first(claims.get(COMPANY_CLAIM)),
"token": token,
}

async def get_current_user(
self,
credentials: HTTPAuthorizationCredentials = Depends(security)
self,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
) -> dict:
"""Extract and validate user from JWT token"""
token = credentials.credentials

"""Require a valid bearer token; 401 otherwise."""
if credentials is None or not credentials.credentials:
raise _unauthorized("Missing bearer token")

return self.build_user(self.decode_token(credentials.credentials),
credentials.credentials)

def get_optional_user(self, request: Request) -> Optional[dict]:
"""Identity when a valid token is present, ``None`` otherwise.

An invalid token returns None rather than raising, so attaching a stale
token can never turn a working API-key call into a 401.
"""
header = request.headers.get("Authorization", "")
if not header.lower().startswith("bearer "):
return None

token = header.split(" ", 1)[1].strip()
try:
decoded = jwt.decode(
token,
key="",
options={"verify_signature": False}
)

username = decoded.get("preferred_username", "unknown")
logger.info(f"[Keycloak] Authenticated user: {username}")

return {
"preferred_username": username,
"email": decoded.get("email", ""),
"given_name": decoded.get("given_name", ""),
"family_name": decoded.get("family_name", ""),
"name": decoded.get("name", ""),
"roles": decoded.get("realm_access", {}).get("roles", []),
"sub": decoded.get("sub", ""),
"token": token
}
except JWTError as e:
logger.error(f"[Keycloak] JWT validation failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return self.build_user(self.decode_token(token), token) if token else None
except HTTPException as exception:
logger.warning("[Keycloak] Ignoring unusable bearer token: %s", exception.detail)
return None

keycloak_openid = KeycloakOpenID()

# Token-Verifikation lockern (nur zum Testen)
keycloak_openid._verify_audience = False
def _unverified_issuer(token: str) -> str:
"""The token's own `iss`, read without verifying. For error messages only."""
try:
return jwt.get_unverified_claims(token).get("iss", "")
except Exception:
return "<unreadable>"


def _env_flag(name: str, default: bool) -> bool:
raw = os.getenv(name)
return default if raw is None else raw.strip().lower() in {"1", "true", "yes", "on"}


def _unauthorized(detail: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail,
headers={"WWW-Authenticate": "Bearer"})


keycloak_openid = KeycloakOpenID()
Loading