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"