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 ea2484348 Browser-OIDC (PKCE) auth: validate the Keycloak token 
server-side (#245)
ea2484348 is described below

commit ea2484348a8f773dd6b3a3cfe5574797a15b234e
Author: Yasith Jayawardana <[email protected]>
AuthorDate: Sat Jun 20 18:31:29 2026 -0400

    Browser-OIDC (PKCE) auth: validate the Keycloak token server-side (#245)
    
    * refactor(auth): stop injecting client-asserted identity into gRPC calls
    
    The Airavata server now derives identity from the verified access token, so 
the
    portal no longer builds or passes a claims map (x-claims) when constructing 
the
    SDK client. The user's token alone carries identity.
    
    * feat(auth): add browser OIDC (PKCE) client module [WIP]
    
    Foundation for moving the OIDC flow into the browser: an oidc-client-ts 
UserManager
    configured for the public `pga-public` Keycloak client (Authorization Code 
+ PKCE),
    with the access token mirrored to a `kc_token` cookie + a synchronous 
accessor so
    Django can authenticate page navigations and FetchUtils can attach a Bearer.
    
    Not yet wired in (base.html bootstrap, FetchUtils Bearer, Django 
cookie-token
    middleware, public pages) — the running portal still uses the session flow.
    
    * feat(auth): browser-OIDC (PKCE) login; validate Keycloak token server-side
    
    Move the OIDC flow into the browser and make the portal Keycloak-token-only,
    matching the token-authoritative airavata server:
    
    - keycloak_token_user_middleware validates the access token from the
      Authorization: Bearer header or the kc_token cookie (JWKS) and sets
      request.user / request.authz_token. Replaces the server-side OIDC session
      (session_keycloak_user_middleware + authz_token_middleware +
      keycloak_bearer_middleware).
    - oidc_login / oidc_callback render small vanilla PKCE pages that run the
      Authorization Code + PKCE flow against the public pga-public Keycloak 
client
      and store the access token in the kc_token cookie; logout clears the 
cookie +
      Keycloak end-session. No server-side client secret, no session-stored 
token.
    - Removes the WIP oidc-client-ts module (buildless vanilla PKCE instead) and
      guards the post-login redirect against open-redirect.
    
    Verified: PKCE login -> token; kc_token cookie -> request.user 
(default-admin,
    admin flag) -> authenticated page + protected /api 200.
---
 .../django_airavata/airavata_grpc.py               |   6 +-
 .../django_airavata/apps/auth/middleware.py        | 131 +++++----------------
 .../templates/django_airavata_auth/callback.html   | 120 +++++++++++++++++++
 .../auth/templates/django_airavata_auth/login.html |  94 +++++++++++++++
 .../django_airavata/apps/auth/views.py             | 121 ++++++++-----------
 airavata-django-portal/django_airavata/settings.py |  11 +-
 6 files changed, 295 insertions(+), 188 deletions(-)

diff --git a/airavata-django-portal/django_airavata/airavata_grpc.py 
b/airavata-django-portal/django_airavata/airavata_grpc.py
index 4f281063e..d34e524d1 100644
--- a/airavata-django-portal/django_airavata/airavata_grpc.py
+++ b/airavata-django-portal/django_airavata/airavata_grpc.py
@@ -17,7 +17,7 @@ from django.conf import settings
 logger = logging.getLogger(__name__)
 
 
-def build_airavata_client(access_token, gateway_id=None, claims=None):
+def build_airavata_client(access_token, gateway_id=None):
     """Build an :class:`AiravataClient` for the given Keycloak access token.
 
     The SDK is imported lazily so importing this module does not require the 
new
@@ -32,7 +32,6 @@ def build_airavata_client(access_token, gateway_id=None, 
claims=None):
         token=access_token,
         gateway_id=gateway_id,
         secure=settings.GRPC_API_SECURE,
-        claims=claims,
     )
 
 
@@ -45,5 +44,4 @@ def airavata_client_for_request(request):
     authz_token = getattr(request, "authz_token", None)
     if authz_token is None:
         return None
-    claims = dict(authz_token.claimsMap) if authz_token.claimsMap else None
-    return build_airavata_client(authz_token.accessToken, claims=claims)
+    return build_airavata_client(authz_token.accessToken)
diff --git a/airavata-django-portal/django_airavata/apps/auth/middleware.py 
b/airavata-django-portal/django_airavata/apps/auth/middleware.py
index bcb943773..d34ce4ec9 100644
--- a/airavata-django-portal/django_airavata/apps/auth/middleware.py
+++ b/airavata-django-portal/django_airavata/apps/auth/middleware.py
@@ -2,7 +2,6 @@
 
 import json
 import logging
-import time
 
 from django.conf import settings
 
@@ -12,28 +11,6 @@ from .token_authentication import AnonymousUser
 log = logging.getLogger(__name__)
 
 
-def authz_token_middleware(get_response):
-    """Automatically add the 'authz_token' to the request."""
-
-    def middleware(request):
-
-        authz_token = None
-        if request.user.is_authenticated:
-            authz_token = utils.get_authz_token(request)
-            # If we can't construct an authz_token then need to re-login
-            if authz_token is None:
-                # no longer logged in with the IAM server: clear the session
-                # (cache-backed, no DB) and drop back to anonymous
-                request.session.flush()
-                request.user = AnonymousUser()
-
-        request.authz_token = authz_token
-
-        return get_response(request)
-
-    return middleware
-
-
 # Keycloak realm roles that map to the coarse gateway-admin flags.
 GATEWAY_ADMIN_ROLE = "admin-rw"
 READ_ONLY_ADMIN_ROLE = "admin-ro"
@@ -93,95 +70,43 @@ def request_data_middleware(get_response):
     return middleware
 
 
-def session_keycloak_user_middleware(get_response):
-    """Derive ``request.user`` from the Keycloak access token in the session.
+def keycloak_token_user_middleware(get_response):
+    """Authenticate every request from a Keycloak access token (browser-OIDC).
 
-    Replaces ``AuthenticationMiddleware`` for the OIDC session flow: there is 
no
-    DB ``User`` and no ``login()`` call, so identity comes from the verified 
JWT
-    stored in the session by the OIDC callback. If the access token is expired
-    but a valid refresh token is present, the token is refreshed in place. On 
any
-    failure the session is flushed and the request is left Anonymous (the
-    permission/login_required layer then redirects to Keycloak).
+    The token comes from either the ``Authorization: Bearer <jwt>`` header
+    (token clients / the SDK) or the ``kc_token`` cookie (the browser PKCE flow
+    sets this cookie from the access token returned by Keycloak). There is no
+    Django session, no server-side OIDC redirect, and no Django-managed login —
+    identity is derived purely from the verified JWT.
 
-    Must run before ``authz_token_middleware`` so ``request.user`` is set when
-    ``get_authz_token`` runs, and before ``keycloak_bearer_middleware`` (which
-    no-ops when a session already authenticated the request).
-    """
-    import jwt
-
-    from .token_authentication import KeycloakUser, _jwks
-
-    def _decode(token):
-        signing_key = _jwks().get_signing_key_from_jwt(token)
-        return jwt.decode(
-            token, signing_key.key, algorithms=["RS256"], 
options={"verify_aud": False}
-        )
+    Reuses ``token_authentication``'s ``_jwks`` / ``KeycloakUser``: the token 
is
+    validated (RS256, ``verify_aud`` False) and, on success, ``request.user`` /
+    ``request.authz_token`` are set (``admin_flags_middleware`` then derives 
the
+    admin flags from realm roles). With no token, or an invalid one, the 
request
+    is left Anonymous with ``request.authz_token = None`` (the permission/
+    login_required layer then redirects to Keycloak or returns 401). An invalid
+    token is logged at warning level; it does not raise.
 
-    def middleware(request):
-        # If a prior middleware already authenticated the request, no-op.
-        user = getattr(request, "user", None)
-        if user is not None and getattr(user, "is_authenticated", False):
-            return get_response(request)
-
-        # This middleware replaces AuthenticationMiddleware, so it owns
-        # request.user. Default to Anonymous; the token paths below upgrade it.
-        request.user = AnonymousUser()
-
-        if "ACCESS_TOKEN" not in request.session:
-            return get_response(request)
-
-        now = time.time()
-        access_token = request.session["ACCESS_TOKEN"]
-        access_expires_at = request.session.get("ACCESS_TOKEN_EXPIRES_AT", 0)
-        refresh_expires_at = request.session.get("REFRESH_TOKEN_EXPIRES_AT", 0)
-
-        try:
-            if access_expires_at > now:
-                claims = _decode(access_token)
-                request.user = KeycloakUser(claims)
-            elif "REFRESH_TOKEN" in request.session and refresh_expires_at > 
now:
-                token = utils.refresh_access_token(request)
-                if token is None:
-                    request.session.flush()
-                    return get_response(request)
-                utils.store_token_in_session(request, token)
-                claims = _decode(token["access_token"])
-                request.user = KeycloakUser(claims)
-            # else: token expired and no usable refresh token; leave Anonymous.
-        except Exception as e:
-            log.warning("Failed to derive user from session token: %s", e)
-            request.session.flush()
-
-        return get_response(request)
-
-    return middleware
-
-
-def keycloak_bearer_middleware(get_response):
-    """Authenticate ``Authorization: Bearer <jwt>`` requests against Keycloak.
-
-    Reuses ``token_authentication``'s ``_jwks`` / ``KeycloakUser`` / 
``AuthzToken``:
-    if ``request.user`` is already
-    authenticated (session) this is a no-op; elif a Bearer token is present it 
is
-    validated and ``request.user`` / ``request.authz_token`` are set (the
-    ``admin_flags_middleware`` then derives the admin flags from realm roles); 
else
-    the request is left Anonymous. An invalid token leaves the user Anonymous 
(no
-    raise — the permission layer returns 401).
+    Must run before ``airavata_grpc_client`` (which reads 
``request.authz_token``)
+    and ``admin_flags_middleware`` (which reads ``request.user``).
     """
     import jwt
 
     from .token_authentication import KeycloakUser, _jwks
 
     def middleware(request):
-        user = getattr(request, "user", None)
-        if user is not None and getattr(user, "is_authenticated", False):
-            return get_response(request)
+        request.user = AnonymousUser()
+        request.authz_token = None
 
         header = request.META.get("HTTP_AUTHORIZATION", "")
-        if not header.startswith("Bearer "):
+        if header.startswith("Bearer "):
+            token = header[len("Bearer ") :].strip()
+        else:
+            token = request.COOKIES.get("kc_token")
+
+        if not token:
             return get_response(request)
 
-        token = header[len("Bearer ") :].strip()
         try:
             signing_key = _jwks().get_signing_key_from_jwt(token)
             claims = jwt.decode(
@@ -191,20 +116,18 @@ def keycloak_bearer_middleware(get_response):
                 options={"verify_aud": False},
             )
         except Exception as e:
-            log.warning("Keycloak bearer token validation failed: %s", e)
-            request.user = AnonymousUser()
+            log.warning("Keycloak token validation failed: %s", e)
             return get_response(request)
 
         keycloak_user = KeycloakUser(claims)
-        authz_token = utils.AuthzToken(
+        request.user = keycloak_user
+        request.authz_token = utils.AuthzToken(
             accessToken=token,
             claimsMap={
                 "gatewayID": settings.GATEWAY_ID,
                 "userName": keycloak_user.username,
             },
         )
-        request.user = keycloak_user
-        request.authz_token = authz_token
 
         return get_response(request)
 
diff --git 
a/airavata-django-portal/django_airavata/apps/auth/templates/django_airavata_auth/callback.html
 
b/airavata-django-portal/django_airavata/apps/auth/templates/django_airavata_auth/callback.html
new file mode 100644
index 000000000..823d79503
--- /dev/null
+++ 
b/airavata-django-portal/django_airavata/apps/auth/templates/django_airavata_auth/callback.html
@@ -0,0 +1,120 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Completing sign-in...</title>
+  <style>
+    body {
+      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 
Helvetica, Arial, sans-serif;
+      margin: 0;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      min-height: 100vh;
+      color: #333;
+    }
+    .message {
+      text-align: center;
+    }
+    .error {
+      color: #b00020;
+    }
+  </style>
+</head>
+<body>
+  <div class="message">
+    <p id="status">Completing sign-in...</p>
+  </div>
+  <script>
+    (function () {
+      var tokenUrl = "{{ token_url|escapejs }}";
+      var clientId = "{{ client_id|escapejs }}";
+      var redirectUri = "{{ redirect_uri|escapejs }}";
+
+      function showError(text) {
+        var status = document.getElementById("status");
+        status.textContent = text;
+        status.className = "error";
+        var link = document.createElement("a");
+        link.href = "/auth/login";
+        link.textContent = "Try signing in again";
+        var p = document.createElement("p");
+        p.appendChild(link);
+        document.querySelector(".message").appendChild(p);
+      }
+
+      var params = new URLSearchParams(window.location.search);
+      var code = params.get("code");
+      var state = params.get("state");
+      var error = params.get("error");
+
+      if (error) {
+        showError("Sign-in failed: " + error);
+        return;
+      }
+
+      if (!code) {
+        showError("Sign-in failed: no authorization code returned.");
+        return;
+      }
+
+      var expectedState = sessionStorage.getItem("kc_oauth_state");
+      if (!state || state !== expectedState) {
+        showError("Sign-in failed: state mismatch.");
+        return;
+      }
+
+      var verifier = sessionStorage.getItem("kc_pkce_verifier");
+      if (!verifier) {
+        showError("Sign-in failed: missing PKCE verifier.");
+        return;
+      }
+
+      var body = new URLSearchParams();
+      body.set("grant_type", "authorization_code");
+      body.set("code", code);
+      body.set("redirect_uri", redirectUri);
+      body.set("client_id", clientId);
+      body.set("code_verifier", verifier);
+
+      fetch(tokenUrl, {
+        method: "POST",
+        headers: { "Content-Type": "application/x-www-form-urlencoded" },
+        body: body.toString()
+      }).then(function (response) {
+        if (!response.ok) {
+          return response.text().then(function (text) {
+            throw new Error("token endpoint returned " + response.status + ": 
" + text);
+          });
+        }
+        return response.json();
+      }).then(function (data) {
+        if (!data || !data.access_token) {
+          throw new Error("no access token in response");
+        }
+
+        var cookie = "kc_token=" + data.access_token + "; Path=/; 
SameSite=Lax";
+        if (window.location.protocol === "https:") {
+          cookie += "; Secure";
+        }
+        document.cookie = cookie;
+
+        var destination = sessionStorage.getItem("kc_post_login_redirect") || 
"/";
+        // Only allow same-origin relative paths (a single leading slash); 
reject
+        // "//host" and absolute URLs to prevent a post-login open redirect.
+        if (!/^\/[^/]/.test(destination) && destination !== "/") {
+          destination = "/";
+        }
+        sessionStorage.removeItem("kc_pkce_verifier");
+        sessionStorage.removeItem("kc_oauth_state");
+        sessionStorage.removeItem("kc_post_login_redirect");
+
+        window.location.replace(destination);
+      }).catch(function (err) {
+        showError("Sign-in failed: " + err.message);
+      });
+    })();
+  </script>
+</body>
+</html>
diff --git 
a/airavata-django-portal/django_airavata/apps/auth/templates/django_airavata_auth/login.html
 
b/airavata-django-portal/django_airavata/apps/auth/templates/django_airavata_auth/login.html
new file mode 100644
index 000000000..120a6ee49
--- /dev/null
+++ 
b/airavata-django-portal/django_airavata/apps/auth/templates/django_airavata_auth/login.html
@@ -0,0 +1,94 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Signing in...</title>
+  <style>
+    body {
+      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 
Helvetica, Arial, sans-serif;
+      margin: 0;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      min-height: 100vh;
+      color: #333;
+    }
+    .message {
+      text-align: center;
+    }
+    .error {
+      color: #b00020;
+    }
+  </style>
+</head>
+<body>
+  <div class="message">
+    <p id="status">Signing in...</p>
+  </div>
+  <script>
+    (function () {
+      var authorizeUrl = "{{ authorize_url|escapejs }}";
+      var clientId = "{{ client_id|escapejs }}";
+      var redirectUri = "{{ redirect_uri|escapejs }}";
+      var scope = "{{ scope|escapejs }}";
+      var next = "{{ next|escapejs }}";
+
+      function base64UrlEncode(bytes) {
+        var binary = "";
+        for (var i = 0; i < bytes.length; i++) {
+          binary += String.fromCharCode(bytes[i]);
+        }
+        return btoa(binary)
+          .replace(/\+/g, "-")
+          .replace(/\//g, "_")
+          .replace(/=+$/, "");
+      }
+
+      function randomVerifier() {
+        var bytes = new Uint8Array(32);
+        window.crypto.getRandomValues(bytes);
+        return base64UrlEncode(bytes);
+      }
+
+      function challengeFromVerifier(verifier) {
+        var data = new TextEncoder().encode(verifier);
+        return window.crypto.subtle.digest("SHA-256", data).then(function 
(digest) {
+          return base64UrlEncode(new Uint8Array(digest));
+        });
+      }
+
+      function showError(text) {
+        var status = document.getElementById("status");
+        status.textContent = text;
+        status.className = "error";
+      }
+
+      try {
+        var verifier = randomVerifier();
+        var state = randomVerifier();
+
+        challengeFromVerifier(verifier).then(function (challenge) {
+          sessionStorage.setItem("kc_pkce_verifier", verifier);
+          sessionStorage.setItem("kc_oauth_state", state);
+          sessionStorage.setItem("kc_post_login_redirect", next || "/");
+
+          window.location =
+            authorizeUrl +
+            "?response_type=code" +
+            "&client_id=" + encodeURIComponent(clientId) +
+            "&redirect_uri=" + encodeURIComponent(redirectUri) +
+            "&scope=" + encodeURIComponent(scope) +
+            "&state=" + state +
+            "&code_challenge=" + challenge +
+            "&code_challenge_method=S256";
+        }).catch(function (err) {
+          showError("Unable to start sign-in: " + err);
+        });
+      } catch (err) {
+        showError("Unable to start sign-in: " + err);
+      }
+    })();
+  </script>
+</body>
+</html>
diff --git a/airavata-django-portal/django_airavata/apps/auth/views.py 
b/airavata-django-portal/django_airavata/apps/auth/views.py
index ee99f7eb5..f03c2b6c8 100644
--- a/airavata-django-portal/django_airavata/apps/auth/views.py
+++ b/airavata-django-portal/django_airavata/apps/auth/views.py
@@ -5,7 +5,6 @@ from urllib.parse import quote, urlencode, urlparse
 
 import requests
 from django.conf import settings
-from django.contrib import messages
 from django.core.exceptions import PermissionDenied
 from django.http import (
     FileResponse,
@@ -16,7 +15,6 @@ from django.http import (
 from django.shortcuts import redirect, render
 from django.template.loader import render_to_string
 from django.urls import reverse
-from requests_oauthlib import OAuth2Session
 
 from . import utils
 from .decorators import login_required
@@ -25,99 +23,72 @@ logger = logging.getLogger(__name__)
 
 
 # ---------------------------------------------------------------------------
-# Keycloak-only OIDC views (Authorization Code flow; no Django auth backend,
-# no DB User, no login()/logout()). Identity is derived from the session token
-# by session_keycloak_user_middleware.
+# Browser-OIDC views (Authorization Code + PKCE against the Keycloak PUBLIC
+# client). There is no server-side OIDC redirect and no Django-managed session:
+# the browser runs the PKCE flow, the code-for-token exchange happens
+# client-side in the callback template, and the raw access token is stored in
+# the ``kc_token`` cookie. Django only VALIDATES that token
+# (``keycloak_token_user_middleware``).
 # ---------------------------------------------------------------------------
 
 
 def oidc_login(request):
-    """Begin the Authorization Code flow: redirect the user to Keycloak."""
+    """Render the browser-PKCE initiation page.
+
+    The template (``django_airavata_auth/login.html``) generates the PKCE
+    verifier/challenge and CSRF state in the browser, stashes them in
+    sessionStorage (``kc_pkce_verifier`` / ``kc_oauth_state``) along with the
+    post-login destination (``kc_post_login_redirect``), then redirects to
+    Keycloak's authorization endpoint with the public client id and S256
+    challenge.
+    """
     redirect_uri = 
request.build_absolute_uri(reverse("django_airavata_auth:callback"))
-    # Preserve the desktop/CLI passthrough params on the callback (mirrors the
-    # legacy redirect_login behavior) so the desktop login flow keeps working.
-    passthrough_query_params = ("next", "login_desktop", "download-code", 
"show-code")
-    extra = []
-    for param in passthrough_query_params:
-        if param in request.GET:
-            extra.append(f"{param}={quote(request.GET[param])}")
-    if extra:
-        redirect_uri += "?" + "&".join(extra)
-    oauth2_session = OAuth2Session(
-        settings.KEYCLOAK_CLIENT_ID,
-        scope="openid profile email",
-        redirect_uri=redirect_uri,
-    )
-    authorization_url, state = oauth2_session.authorization_url(
-        settings.KEYCLOAK_AUTHORIZE_URL
-    )
-    request.session["OAUTH2_STATE"] = state
-    request.session["OAUTH2_REDIRECT_URI"] = redirect_uri
-    return redirect(authorization_url)
+    context = {
+        "authorize_url": settings.KEYCLOAK_AUTHORIZE_URL,
+        "token_url": settings.KEYCLOAK_TOKEN_URL,
+        "client_id": settings.KEYCLOAK_PUBLIC_CLIENT_ID,
+        "redirect_uri": redirect_uri,
+        "scope": "openid profile email",
+        "next": request.GET.get("next", "/"),
+    }
+    return render(request, "django_airavata_auth/login.html", context)
 
 
 def oidc_callback(request):
-    """Handle the Keycloak redirect: exchange the code, store the token."""
-    from .token_authentication import KeycloakUser
-
-    try:
-        state = request.GET.get("state")
-        if not state or state != request.session.get("OAUTH2_STATE"):
-            raise Exception("OAuth2 state mismatch")
-        login_desktop = request.GET.get("login_desktop", "false") == "true"
-        token = utils.exchange_code_for_token(request)
-        utils.store_token_in_session(request, token)
-        # session_keycloak_user_middleware already ran (before the token
-        # existed), so set request.user inline from the freshly-issued token 
for
-        # any helper that reads request.user (e.g. the desktop-success 
response).
-        import jwt
-
-        from .token_authentication import _jwks
-
-        signing_key = _jwks().get_signing_key_from_jwt(token["access_token"])
-        claims = jwt.decode(
-            token["access_token"],
-            signing_key.key,
-            algorithms=["RS256"],
-            options={"verify_aud": False},
-        )
-        request.user = KeycloakUser(claims)
-        if login_desktop:
-            download_code = request.GET.get("download-code", "false") == "true"
-            show_code = request.GET.get("show-code", "false") == "true"
-            return _create_login_desktop_success_response(
-                request, download_code=download_code, show_code=show_code
-            )
-        next_url = request.GET.get("next", settings.LOGIN_REDIRECT_URL)
-        return redirect(next_url)
-    except Exception as err:
-        logger.exception(
-            f"An error occurred while processing OAuth2 callback: 
{request.build_absolute_uri()}",
-            extra={"request": request},
-        )
-        if request.GET.get("login_desktop", "false") == "true":
-            return _create_login_desktop_failed_response(request)
-        messages.error(request, f"Failed to process OAuth2 callback: {err!s}")
-        return redirect(settings.LOGIN_URL)
+    """Render the redirect_uri page that exchanges the code client-side.
+
+    The template (``django_airavata_auth/callback.html``) reads the ``code`` 
and
+    ``state`` query params, validates ``state`` against the sessionStorage
+    ``kc_oauth_state``, POSTs the authorization-code grant to Keycloak's token
+    endpoint (public client + PKCE ``code_verifier`` from
+    ``kc_pkce_verifier``), sets the ``kc_token`` cookie from the returned
+    access token, then redirects to ``kc_post_login_redirect``.
+    """
+    redirect_uri = 
request.build_absolute_uri(reverse("django_airavata_auth:callback"))
+    context = {
+        "token_url": settings.KEYCLOAK_TOKEN_URL,
+        "client_id": settings.KEYCLOAK_PUBLIC_CLIENT_ID,
+        "redirect_uri": redirect_uri,
+    }
+    return render(request, "django_airavata_auth/callback.html", context)
 
 
 def logout(request):
-    """Log out locally and at Keycloak (federated / single logout)."""
-    request.session.flush()
-    post_logout_redirect_uri = request.build_absolute_uri(
-        reverse("django_airavata_auth:logged_out")
-    )
+    """Clear the ``kc_token`` cookie and log out at Keycloak (single 
logout)."""
+    post_logout_redirect_uri = request.build_absolute_uri("/")
     logout_url = (
         settings.KEYCLOAK_LOGOUT_URL
         + "?"
         + urlencode(
             {
-                "client_id": settings.KEYCLOAK_CLIENT_ID,
+                "client_id": settings.KEYCLOAK_PUBLIC_CLIENT_ID,
                 "post_logout_redirect_uri": post_logout_redirect_uri,
             }
         )
     )
-    return redirect(logout_url)
+    response = redirect(logout_url)
+    response.delete_cookie("kc_token", path="/", samesite="Lax")
+    return response
 
 
 def logged_out(request):
diff --git a/airavata-django-portal/django_airavata/settings.py 
b/airavata-django-portal/django_airavata/settings.py
index 2d3a18a03..04b16cce1 100644
--- a/airavata-django-portal/django_airavata/settings.py
+++ b/airavata-django-portal/django_airavata/settings.py
@@ -51,13 +51,11 @@ MIDDLEWARE = [
     "django.contrib.sessions.middleware.SessionMiddleware",
     "django.middleware.common.CommonMiddleware",
     "django.middleware.csrf.CsrfViewMiddleware",
-    # request.user from the session's Keycloak token; before the authz/bearer 
middleware.
-    "django_airavata.apps.auth.middleware.session_keycloak_user_middleware",
     "django.contrib.messages.middleware.MessageMiddleware",
     "django.middleware.clickjacking.XFrameOptionsMiddleware",
-    "django_airavata.apps.auth.middleware.authz_token_middleware",
-    # Validate a Bearer JWT for token clients; after authz_token, before the 
gRPC client.
-    "django_airavata.apps.auth.middleware.keycloak_bearer_middleware",
+    # Validate the Keycloak access token (Bearer header or kc_token cookie) and
+    # set request.user / request.authz_token; before the gRPC client.
+    "django_airavata.apps.auth.middleware.keycloak_token_user_middleware",
     # Adds request.data / request.query_params for views.
     "django_airavata.apps.auth.middleware.request_data_middleware",
     # gRPC AiravataClient (request.airavata); after authz_token_middleware.
@@ -370,6 +368,9 @@ GATEWAY_ID = os.environ.get("GATEWAY_ID", "default")
 # Keycloak OIDC (realm: default, client: pga). The secret is the committed dev 
secret.
 KEYCLOAK_CLIENT_ID = "pga"
 KEYCLOAK_CLIENT_SECRET = "m36BXQIxX3j3VILadeHMK5IvbOeRlCCc"
+# Public client (PKCE S256) used by the browser Authorization Code flow. No
+# secret; the token exchange happens client-side in the callback template.
+KEYCLOAK_PUBLIC_CLIENT_ID = "pga-public"
 KEYCLOAK_AUTHORIZE_URL = 
"https://auth.airavata.host/realms/default/protocol/openid-connect/auth";
 KEYCLOAK_TOKEN_URL = 
"https://auth.airavata.host/realms/default/protocol/openid-connect/token";
 KEYCLOAK_USERINFO_URL = 
"https://auth.airavata.host/realms/default/protocol/openid-connect/userinfo";

Reply via email to