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',

Reply via email to