This is an automated email from the ASF dual-hosted git repository.
yasithdev pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airavata-portals.git
The following commit(s) were added to refs/heads/main by this push:
new 9d940a122 feat(portal): pure Keycloak token auth + lazy Thrift client
(Track D, D5 start) (#160)
9d940a122 is described below
commit 9d940a12286c1d50d70275bc6b4e03646a0410be
Author: Yasith Jayawardana <[email protected]>
AuthorDate: Mon Jun 8 18:26:21 2026 -0400
feat(portal): pure Keycloak token auth + lazy Thrift client (Track D, D5
start) (#160)
Per the directive to move auth purely to Keycloak with no separate Django
auth
layer — the portal now just expects a valid token:
- apps/auth/token_authentication.KeycloakTokenAuthentication: validate the
Bearer JWT against the realm JWKS (signature + expiry), derive a
lightweight
non-DB user from the claims, and build request.authz_token directly from
the
token. No session, no DB User, no OAuth login flow. Set as the sole DRF
DEFAULT_AUTHENTICATION_CLASSES (replacing SessionAuthentication +
OAuthAuthentication).
- middleware.AiravataClientMiddleware: attach the Thrift client lazily
(SimpleLazyObject) so requests that only use the gRPC client never open a
Thrift connection — required now that the legacy Thrift server is gone
(the new
backend is gRPC-only on :9090); the per-request eager Thrift connect
otherwise
hangs every request.
Validated against the running tilt backend (manage.py runserver + Bearer
token):
GET /api/projects/ -> 200 paginated, GET /api/applications/ -> 200 [] (both
via
the migrated gRPC reads); missing/invalid token -> 401.
NOTE: this replaces session auth for the API, so the browser frontend must
send
the Keycloak token as a Bearer header (a frontend change) rather than rely
on
the session cookie. The broader removal of the login views /
KeycloakBackend /
DB User mirror continues in D5/D6.
---
.../apps/auth/token_authentication.py | 95 ++++++++++++++++++++++
.../django_airavata/middleware.py | 23 +++++-
airavata-django-portal/django_airavata/settings.py | 5 +-
3 files changed, 117 insertions(+), 6 deletions(-)
diff --git
a/airavata-django-portal/django_airavata/apps/auth/token_authentication.py
b/airavata-django-portal/django_airavata/apps/auth/token_authentication.py
new file mode 100644
index 000000000..33c285396
--- /dev/null
+++ b/airavata-django-portal/django_airavata/apps/auth/token_authentication.py
@@ -0,0 +1,95 @@
+"""Pure Keycloak token authentication.
+
+Track D: the portal expects a valid Keycloak access token and validates it
+against the realm's JWKS — there is no separate Django auth layer (no login
flow,
+no session-stored tokens, no DB ``User``). Identity is derived from the
verified
+JWT claims, and ``request.authz_token`` is built directly from the token so
both
+the gRPC facade (``request.airavata``) and the still-Thrift calls carry it.
+"""
+
+import logging
+import ssl
+
+import jwt
+from airavata.model.security.ttypes import AuthzToken
+from django.conf import settings
+from rest_framework import authentication, exceptions
+
+logger = logging.getLogger(__name__)
+
+_jwks_client = None
+
+
+def _jwks():
+ """Lazily build a cached PyJWKClient for the realm's signing keys."""
+ global _jwks_client
+ if _jwks_client is None:
+ certs_url = settings.KEYCLOAK_TOKEN_URL.rsplit('/', 1)[0] + '/certs'
+ ssl_context = None
+ if not getattr(settings, 'KEYCLOAK_VERIFY_SSL', True):
+ ssl_context = ssl.create_default_context()
+ ssl_context.check_hostname = False
+ ssl_context.verify_mode = ssl.CERT_NONE
+ _jwks_client = jwt.PyJWKClient(certs_url, ssl_context=ssl_context)
+ return _jwks_client
+
+
+class KeycloakUser:
+ """Lightweight, non-DB authenticated user derived from a Keycloak token."""
+
+ is_authenticated = True
+ is_anonymous = False
+ is_active = True
+
+ def __init__(self, claims):
+ self.claims = claims
+ self.username = claims.get('preferred_username') or claims.get('sub')
+ self.email = claims.get('email', '')
+ self.first_name = claims.get('given_name', '')
+ self.last_name = claims.get('family_name', '')
+
+ def __str__(self):
+ return self.username or '<anonymous>'
+
+ @property
+ def is_staff(self):
+ return False
+
+
+class KeycloakTokenAuthentication(authentication.BaseAuthentication):
+ """Validate a Keycloak Bearer token; no session, no DB user.
+
+ On success sets ``request.authz_token`` (Keycloak access token +
gateway/user
+ claims) so downstream gRPC/Thrift calls are authenticated.
+ """
+
+ def authenticate(self, request):
+ header = request.META.get('HTTP_AUTHORIZATION', '')
+ if not header.startswith('Bearer '):
+ return None
+ token = header[len('Bearer '):].strip()
+ try:
+ signing_key = _jwks().get_signing_key_from_jwt(token)
+ claims = jwt.decode(
+ token, signing_key.key, algorithms=['RS256'],
+ options={'verify_aud': False})
+ except Exception as e: # noqa: BLE001 - any failure is an auth failure
+ logger.warning("Keycloak token validation failed: %s", e)
+ raise exceptions.AuthenticationFailed("Invalid or expired token")
+
+ user = KeycloakUser(claims)
+ authz_token = AuthzToken(
+ accessToken=token,
+ claimsMap={'gatewayID': settings.GATEWAY_ID,
+ 'userName': user.username})
+ # Set on both the DRF Request and the underlying HttpRequest:
middleware
+ # (e.g. the lazy gRPC client builder) closes over the HttpRequest,
while
+ # views/serializers read it through the DRF Request wrapper.
+ request.authz_token = authz_token
+ if hasattr(request, '_request'):
+ request._request.authz_token = authz_token
+ request._request.user = user
+ return (user, token)
+
+ def authenticate_header(self, request):
+ return 'Bearer'
diff --git a/airavata-django-portal/django_airavata/middleware.py
b/airavata-django-portal/django_airavata/middleware.py
index cc1eb7462..b5a398fde 100644
--- a/airavata-django-portal/django_airavata/middleware.py
+++ b/airavata-django-portal/django_airavata/middleware.py
@@ -14,11 +14,26 @@ class AiravataClientMiddleware:
self.get_response = get_response
def __call__(self, request):
- with utils.airavata_api_client_pool.connection() as airavata_client:
- request.airavata_client = airavata_client
- response = self.get_response(request)
+ # Track D: attach the Thrift client lazily so requests that only use
the
+ # gRPC client (request.airavata) never open a Thrift connection. The
+ # connection is acquired from the pool on first access and released
after
+ # the response. Views/serializers still on Thrift work unchanged.
+ from django.utils.functional import SimpleLazyObject
- return response
+ opened = []
+
+ def _client():
+ ctx = utils.airavata_api_client_pool.connection()
+ client = ctx.__enter__()
+ opened.append(ctx)
+ return client
+
+ request.airavata_client = SimpleLazyObject(_client)
+ try:
+ return self.get_response(request)
+ finally:
+ for ctx in opened:
+ ctx.__exit__(None, None, None)
def process_exception(self, request, exception):
if isinstance(exception,
thrift.transport.TTransport.TTransportException):
diff --git a/airavata-django-portal/django_airavata/settings.py
b/airavata-django-portal/django_airavata/settings.py
index d33cccdeb..1ac1baad9 100644
--- a/airavata-django-portal/django_airavata/settings.py
+++ b/airavata-django-portal/django_airavata/settings.py
@@ -213,9 +213,10 @@ PORTAL_CHROME = {
# Django REST Framework configuration
REST_FRAMEWORK = {
+ # Track D: authenticate purely from a Keycloak token (no session, no DB
user,
+ # no separate Django auth layer). The portal just expects a valid token.
'DEFAULT_AUTHENTICATION_CLASSES': (
- 'rest_framework.authentication.SessionAuthentication',
- 'django_airavata.apps.api.authentication.OAuthAuthentication',
+
'django_airavata.apps.auth.token_authentication.KeycloakTokenAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',