This is an automated email from the ASF dual-hosted git repository.

yasithdev pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/airavata.git


The following commit(s) were added to refs/heads/master by this push:
     new b10d094dad Token-authoritative auth: derive caller identity from the 
verified Keycloak token (#695)
b10d094dad is described below

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

    Token-authoritative auth: derive caller identity from the verified Keycloak 
token (#695)
    
    * feat(auth): derive caller identity from the verified access token
    
    Establish identity solely from the signature-verified Keycloak token 
instead of
    trusting client-supplied x-claims headers, and reject tokens that fail 
verification.
    
    - JwtVerifier.verify() returns a VerifiedToken (userName from 
preferred_username,
      gatewayId from the issuer realm, realm roles) and throws 
TokenVerificationException
      on a missing, malformed, expired, or unverifiable token.
    - GrpcAuthInterceptor and HttpAuthDecorator reject such requests with
      UNAUTHENTICATED / 401 instead of proceeding as a non-admin.
    - AuthTokenExtractor builds the AuthzToken claims map from the verified 
token only;
      x-claims, x-user-name, and x-gateway-id are no longer read.
    
    A Keycloak access token is now sufficient on its own to call the API: every 
client
    (browser SPA, SDK, portal) is authenticated and identified identically, and 
identity
    can no longer be asserted via headers. UserContext and its readers are 
unchanged.
    
    Adds VerifiedToken, TokenVerificationException, and JwtVerifierTest.
    
    * feat(sdk): send only the Keycloak access token for auth
    
    The server now derives identity from the verified token, so the client no 
longer
    sends x-claims. AiravataClient sends the access token as a Bearer 
credential only and
    decodes username/gateway from the token locally for convenience.
    
    * build(tilt): clean install in the build step
    
    Run `mvn clean install` so a stale target/ (e.g. orphaned protobuf-generated
    sources after a proto refactor) cannot break the Tilt build.
    
    * feat(sdk): token-only auth across all client and transport paths
    
    Extend the token-only change to the remaining SDK surfaces so a Keycloak 
access
    token is the sole credential everywhere and no client-asserted identity is 
sent:
    
    - clients/*.py (14 service clients) and transport/utils.py no longer append 
the
      x-claims metadata.
    - airavata_experiments/plan.py and airavata_jupyter_magic no longer send the
      X-Claims header; the now-dead claims-building is removed.
    
    The server derives identity from the verified token, so x-claims was 
redundant.
    
    * feat(keycloak): add pga-public client (public, PKCE) for browser OIDC
    
    A public PKCE client for the portal SPA to run the Authorization Code + 
PKCE flow
    in the browser, so identity is obtained from Keycloak client-side and the 
access
    token is sent to airavata directly. Confidential `pga` client is unchanged.
---
 Tiltfile                                           |   2 +-
 airavata-python-sdk/airavata_experiments/plan.py   |   6 --
 .../airavata_jupyter_magic/__init__.py             |  23 -----
 airavata-python-sdk/airavata_sdk/client.py         |  28 +++--
 .../clients/agent_interaction_client.py            |   2 -
 .../airavata_sdk/clients/api_server_client.py      |   2 -
 .../clients/credential_store_client.py             |   2 -
 .../clients/experiment_management_client.py        |   2 -
 .../airavata_sdk/clients/group_manager_client.py   |   2 -
 .../airavata_sdk/clients/iam_admin_client.py       |   2 -
 .../airavata_sdk/clients/plan_client.py            |   2 -
 .../airavata_sdk/clients/research_hub_client.py    |   2 -
 .../clients/research_project_client.py             |   2 -
 .../clients/research_resource_client.py            |   2 -
 .../clients/research_session_client.py             |   2 -
 .../clients/sharing_registry_client.py             |   2 -
 .../airavata_sdk/clients/tenant_profile_client.py  |   2 -
 .../airavata_sdk/clients/user_profile_client.py    |   2 -
 .../airavata_sdk/transport/utils.py                |   4 -
 .../server/grpc/config/AuthTokenExtractor.java     |  43 +++-----
 .../server/grpc/config/GrpcAuthInterceptor.java    |  14 ++-
 .../server/grpc/config/HttpAuthDecorator.java      |  26 ++---
 .../airavata/server/grpc/config/JwtVerifier.java   |  67 ++++++++----
 .../grpc/config/TokenVerificationException.java    |  32 ++++++
 .../airavata/server/grpc/config/VerifiedToken.java |  29 ++++++
 .../server/grpc/config/JwtVerifierTest.java        |  74 +++++++++++++
 conf/keycloak/realm-default.json                   | 114 +++++++++++++++++++++
 27 files changed, 350 insertions(+), 140 deletions(-)

diff --git a/Tiltfile b/Tiltfile
index bd65bdf14f..895a081085 100644
--- a/Tiltfile
+++ b/Tiltfile
@@ -12,7 +12,7 @@ local_resource('devstack-ensure', cmd='./devstack/devstack 
ensure', labels=['pla
 # (encrypted with the committed keystore). It must NOT be regenerated, or the 
seed would no
 # longer decrypt to it.
 
-local_resource('build', cmd='mvn install -DskipTests -Dmaven.test.skip=true 
-T4 -q',
+local_resource('build', cmd='mvn clean install -DskipTests 
-Dmaven.test.skip=true -T4 -q',
                deps=['airavata-server/pom.xml'], labels=['build'])
 
 docker_build('airavata-server:dev', '.', dockerfile='Dockerfile',
diff --git a/airavata-python-sdk/airavata_experiments/plan.py 
b/airavata-python-sdk/airavata_experiments/plan.py
index 69b08adc7b..b03267dd7b 100644
--- a/airavata-python-sdk/airavata_experiments/plan.py
+++ b/airavata-python-sdk/airavata_experiments/plan.py
@@ -135,11 +135,9 @@ class Plan(pydantic.BaseModel):
     av = AiravataOperator(AuthContext.get_access_token())
     az = av.__airavata_token__(av.access_token, av.default_gateway_id())
     assert az.access_token is not None
-    assert az.claims_map is not None
     headers = {
         'Content-Type': 'application/json',
         'Authorization': 'Bearer ' + az.access_token,
-        'X-Claims': json.dumps(dict(az.claims_map))
     }
     import requests
     if self.id is None:
@@ -168,11 +166,9 @@ def load(id: str | None) -> Plan:
     av = AiravataOperator(AuthContext.get_access_token())
     az = av.__airavata_token__(av.access_token, av.default_gateway_id())
     assert az.access_token is not None
-    assert az.claims_map is not None
     headers = {
         'Content-Type': 'application/json',
         'Authorization': 'Bearer ' + az.access_token,
-        'X-Claims': json.dumps(dict(az.claims_map))
     }
     import requests
     response = requests.get(f"{settings.API_SERVER_URL}/api/v1/plan/{id}", 
headers=headers)
@@ -189,11 +185,9 @@ def query() -> list[Plan]:
     av = AiravataOperator(AuthContext.get_access_token())
     az = av.__airavata_token__(av.access_token, av.default_gateway_id())
     assert az.access_token is not None
-    assert az.claims_map is not None
     headers = {
         'Content-Type': 'application/json',
         'Authorization': 'Bearer ' + az.access_token,
-        'X-Claims': json.dumps(dict(az.claims_map))
     }
     import requests
     response = requests.get(f"{settings.API_SERVER_URL}/api/v1/plan/user", 
headers=headers)
diff --git a/airavata-python-sdk/airavata_jupyter_magic/__init__.py 
b/airavata-python-sdk/airavata_jupyter_magic/__init__.py
index c445e654b2..b30f924f3c 100644
--- a/airavata-python-sdk/airavata_jupyter_magic/__init__.py
+++ b/airavata-python-sdk/airavata_jupyter_magic/__init__.py
@@ -430,16 +430,9 @@ def generate_headers(access_token: str, gateway_id: str) 
-> dict:
     @returns: the headers
 
     """
-    decode = jwt.decode(access_token, options={"verify_signature": False})
-    user_id = decode['preferred_username']
-    claimsMap = {
-        "userName": user_id,
-        "gatewayID": gateway_id
-    }
     return {
         'Content-Type': 'application/json',
         'Authorization': 'Bearer ' + access_token,
-        'X-Claims': json.dumps(claimsMap)
     }
 
 
@@ -682,18 +675,10 @@ def restart_runtime_kernel(access_token: str, rt_name: 
str, env_name: str, runti
     settings = Settings()
     url = f"{settings.API_SERVER_URL}/api/v1/agent/setup/restart"
 
-    decode = jwt.decode(access_token, options={"verify_signature": False})
-    user_id = decode['preferred_username']
-    claimsMap = {
-        "userName": user_id,
-        "gatewayID": runtime.gateway_id
-    }
-
     # Headers
     headers = {
         'Content-Type': 'application/json',
         'Authorization': 'Bearer ' + access_token,
-        'X-Claims': json.dumps(claimsMap)
     }
 
     # Send the POST request
@@ -732,18 +717,10 @@ def stop_agent_job(access_token: str, runtime_name: str, 
runtime: RuntimeInfo):
     settings = Settings()
     url = 
f"{settings.API_SERVER_URL}/api/v1/exp/terminate/{runtime.experimentId}"
 
-    decode = jwt.decode(access_token, options={"verify_signature": False})
-    user_id = decode['preferred_username']
-    claimsMap = {
-        "userName": user_id,
-        "gatewayID": runtime.gateway_id
-    }
-
     # Headers
     headers = {
         'Content-Type': 'application/json',
         'Authorization': 'Bearer ' + access_token,
-        'X-Claims': json.dumps(claimsMap)
     }
 
     # Send the POST request
diff --git a/airavata-python-sdk/airavata_sdk/client.py 
b/airavata-python-sdk/airavata_sdk/client.py
index 69896999a7..4c1ffcb43b 100644
--- a/airavata-python-sdk/airavata_sdk/client.py
+++ b/airavata-python-sdk/airavata_sdk/client.py
@@ -1,12 +1,26 @@
+import base64
 import json
 import logging
-from typing import Optional
 
 import grpc
 
 log = logging.getLogger(__name__)
 
 
+def _decode_jwt_claims(token):
+    """Decode a JWT payload (no signature verification) to read identity 
claims for convenience.
+
+    Used only to expose ``username``/``gateway_id`` locally; authorization is 
the token itself,
+    which the server verifies and from which it derives identity.
+    """
+    try:
+        payload = token.split(".")[1]
+        payload += "=" * (-len(payload) % 4)
+        return json.loads(base64.urlsafe_b64decode(payload))
+    except Exception:
+        return {}
+
+
 class AiravataClient:
     """Transport-agnostic facade over all Airavata gRPC services.
 
@@ -24,13 +38,15 @@ class AiravataClient:
         else:
             self._channel = grpc.insecure_channel(target, options=options)
 
-        self.claims = claims or {}
+        # Auth is the Keycloak access token and nothing else: it is sent as a 
Bearer
+        # credential and the server derives identity (user + gateway) from the 
verified
+        # token. Client-asserted identity (x-claims) is no longer sent.
+        self._token_claims = _decode_jwt_claims(token) if token else {}
+        self.claims = self._token_claims
 
         self._metadata = []
         if token:
             self._metadata.append(("authorization", f"Bearer {token}"))
-        if claims:
-            self._metadata.append(("x-claims", json.dumps(claims)))
 
         self._gateway_id = gateway_id
 
@@ -49,8 +65,8 @@ class AiravataClient:
 
     @property
     def username(self):
-        """Return the caller's username from JWT claims, or None if 
unavailable."""
-        return (self.claims or {}).get("userName")
+        """Return the caller's username decoded from the access token, or 
None."""
+        return self._token_claims.get("preferred_username")
 
     @property
     def gateway_id(self):
diff --git 
a/airavata-python-sdk/airavata_sdk/clients/agent_interaction_client.py 
b/airavata-python-sdk/airavata_sdk/clients/agent_interaction_client.py
index 3327619091..5bdf8605e4 100644
--- a/airavata-python-sdk/airavata_sdk/clients/agent_interaction_client.py
+++ b/airavata-python-sdk/airavata_sdk/clients/agent_interaction_client.py
@@ -49,8 +49,6 @@ class AgentInteractionClient:
         self._metadata: list[tuple[str, str]] = []
         if access_token:
             self._metadata.append(("authorization", f"Bearer {access_token}"))
-        if claims:
-            self._metadata.append(("x-claims", json.dumps(claims)))
 
         self._stub = create_agent_interaction_service_stub(self.channel)
 
diff --git a/airavata-python-sdk/airavata_sdk/clients/api_server_client.py 
b/airavata-python-sdk/airavata_sdk/clients/api_server_client.py
index 22a1aaa61f..e1dbb826a7 100644
--- a/airavata-python-sdk/airavata_sdk/clients/api_server_client.py
+++ b/airavata-python-sdk/airavata_sdk/clients/api_server_client.py
@@ -65,8 +65,6 @@ class APIServerClient:
         self._metadata: list[tuple[str, str]] = []
         if access_token:
             self._metadata.append(("authorization", f"Bearer {access_token}"))
-        if claims:
-            self._metadata.append(("x-claims", json.dumps(claims)))
 
         # Create all service stubs
         self._experiment = create_experiment_service_stub(self.channel)
diff --git 
a/airavata-python-sdk/airavata_sdk/clients/credential_store_client.py 
b/airavata-python-sdk/airavata_sdk/clients/credential_store_client.py
index e12fac3ccb..2a73235872 100644
--- a/airavata-python-sdk/airavata_sdk/clients/credential_store_client.py
+++ b/airavata-python-sdk/airavata_sdk/clients/credential_store_client.py
@@ -49,8 +49,6 @@ class CredentialStoreClient:
         self._metadata: list[tuple[str, str]] = []
         if access_token:
             self._metadata.append(("authorization", f"Bearer {access_token}"))
-        if claims:
-            self._metadata.append(("x-claims", json.dumps(claims)))
 
         self._stub = create_credential_service_stub(self.channel)
 
diff --git 
a/airavata-python-sdk/airavata_sdk/clients/experiment_management_client.py 
b/airavata-python-sdk/airavata_sdk/clients/experiment_management_client.py
index 4d0159d15a..0a526aa831 100644
--- a/airavata-python-sdk/airavata_sdk/clients/experiment_management_client.py
+++ b/airavata-python-sdk/airavata_sdk/clients/experiment_management_client.py
@@ -48,8 +48,6 @@ class ExperimentManagementClient:
         self._metadata: list[tuple[str, str]] = []
         if access_token:
             self._metadata.append(("authorization", f"Bearer {access_token}"))
-        if claims:
-            self._metadata.append(("x-claims", json.dumps(claims)))
 
         self._stub = create_experiment_management_service_stub(self.channel)
 
diff --git a/airavata-python-sdk/airavata_sdk/clients/group_manager_client.py 
b/airavata-python-sdk/airavata_sdk/clients/group_manager_client.py
index 348d8060c3..8dc4a22621 100644
--- a/airavata-python-sdk/airavata_sdk/clients/group_manager_client.py
+++ b/airavata-python-sdk/airavata_sdk/clients/group_manager_client.py
@@ -45,8 +45,6 @@ class GroupManagerClient:
         self._metadata: list[tuple[str, str]] = []
         if access_token:
             self._metadata.append(("authorization", f"Bearer {access_token}"))
-        if claims:
-            self._metadata.append(("x-claims", json.dumps(claims)))
 
         self._stub = create_group_manager_service_stub(self.channel)
 
diff --git a/airavata-python-sdk/airavata_sdk/clients/iam_admin_client.py 
b/airavata-python-sdk/airavata_sdk/clients/iam_admin_client.py
index f01ed606fe..e8adfc4041 100644
--- a/airavata-python-sdk/airavata_sdk/clients/iam_admin_client.py
+++ b/airavata-python-sdk/airavata_sdk/clients/iam_admin_client.py
@@ -45,8 +45,6 @@ class IAMAdminClient:
         self._metadata: list[tuple[str, str]] = []
         if access_token:
             self._metadata.append(("authorization", f"Bearer {access_token}"))
-        if claims:
-            self._metadata.append(("x-claims", json.dumps(claims)))
 
         self._stub = create_iam_admin_service_stub(self.channel)
 
diff --git a/airavata-python-sdk/airavata_sdk/clients/plan_client.py 
b/airavata-python-sdk/airavata_sdk/clients/plan_client.py
index e271f258e6..c1f9c62958 100644
--- a/airavata-python-sdk/airavata_sdk/clients/plan_client.py
+++ b/airavata-python-sdk/airavata_sdk/clients/plan_client.py
@@ -49,8 +49,6 @@ class PlanClient:
         self._metadata: list[tuple[str, str]] = []
         if access_token:
             self._metadata.append(("authorization", f"Bearer {access_token}"))
-        if claims:
-            self._metadata.append(("x-claims", json.dumps(claims)))
 
         self._stub = create_plan_service_stub(self.channel)
 
diff --git a/airavata-python-sdk/airavata_sdk/clients/research_hub_client.py 
b/airavata-python-sdk/airavata_sdk/clients/research_hub_client.py
index 7040934d5d..173adeffeb 100644
--- a/airavata-python-sdk/airavata_sdk/clients/research_hub_client.py
+++ b/airavata-python-sdk/airavata_sdk/clients/research_hub_client.py
@@ -48,8 +48,6 @@ class ResearchHubClient:
         self._metadata: list[tuple[str, str]] = []
         if access_token:
             self._metadata.append(("authorization", f"Bearer {access_token}"))
-        if claims:
-            self._metadata.append(("x-claims", json.dumps(claims)))
 
         self._stub = create_research_hub_service_stub(self.channel)
 
diff --git 
a/airavata-python-sdk/airavata_sdk/clients/research_project_client.py 
b/airavata-python-sdk/airavata_sdk/clients/research_project_client.py
index a27eeb09b7..3492a3b372 100644
--- a/airavata-python-sdk/airavata_sdk/clients/research_project_client.py
+++ b/airavata-python-sdk/airavata_sdk/clients/research_project_client.py
@@ -48,8 +48,6 @@ class ResearchProjectClient:
         self._metadata: list[tuple[str, str]] = []
         if access_token:
             self._metadata.append(("authorization", f"Bearer {access_token}"))
-        if claims:
-            self._metadata.append(("x-claims", json.dumps(claims)))
 
         self._stub = create_research_project_service_stub(self.channel)
 
diff --git 
a/airavata-python-sdk/airavata_sdk/clients/research_resource_client.py 
b/airavata-python-sdk/airavata_sdk/clients/research_resource_client.py
index 13197b39d1..4328e41e6d 100644
--- a/airavata-python-sdk/airavata_sdk/clients/research_resource_client.py
+++ b/airavata-python-sdk/airavata_sdk/clients/research_resource_client.py
@@ -50,8 +50,6 @@ class ResearchResourceClient:
         self._metadata: list[tuple[str, str]] = []
         if access_token:
             self._metadata.append(("authorization", f"Bearer {access_token}"))
-        if claims:
-            self._metadata.append(("x-claims", json.dumps(claims)))
 
         self._stub = create_research_resource_service_stub(self.channel)
 
diff --git 
a/airavata-python-sdk/airavata_sdk/clients/research_session_client.py 
b/airavata-python-sdk/airavata_sdk/clients/research_session_client.py
index 737c1f9e65..a9d9d5d93d 100644
--- a/airavata-python-sdk/airavata_sdk/clients/research_session_client.py
+++ b/airavata-python-sdk/airavata_sdk/clients/research_session_client.py
@@ -48,8 +48,6 @@ class ResearchSessionClient:
         self._metadata: list[tuple[str, str]] = []
         if access_token:
             self._metadata.append(("authorization", f"Bearer {access_token}"))
-        if claims:
-            self._metadata.append(("x-claims", json.dumps(claims)))
 
         self._stub = create_research_session_service_stub(self.channel)
 
diff --git 
a/airavata-python-sdk/airavata_sdk/clients/sharing_registry_client.py 
b/airavata-python-sdk/airavata_sdk/clients/sharing_registry_client.py
index 8de1fa4df3..adc7f014da 100644
--- a/airavata-python-sdk/airavata_sdk/clients/sharing_registry_client.py
+++ b/airavata-python-sdk/airavata_sdk/clients/sharing_registry_client.py
@@ -50,8 +50,6 @@ class SharingRegistryClient:
         self._metadata: list[tuple[str, str]] = []
         if access_token:
             self._metadata.append(("authorization", f"Bearer {access_token}"))
-        if claims:
-            self._metadata.append(("x-claims", json.dumps(claims)))
 
         self._stub = create_sharing_service_stub(self.channel)
 
diff --git a/airavata-python-sdk/airavata_sdk/clients/tenant_profile_client.py 
b/airavata-python-sdk/airavata_sdk/clients/tenant_profile_client.py
index 96e22f74c6..6639de91a1 100644
--- a/airavata-python-sdk/airavata_sdk/clients/tenant_profile_client.py
+++ b/airavata-python-sdk/airavata_sdk/clients/tenant_profile_client.py
@@ -50,8 +50,6 @@ class TenantProfileClient:
         self._metadata: list[tuple[str, str]] = []
         if access_token:
             self._metadata.append(("authorization", f"Bearer {access_token}"))
-        if claims:
-            self._metadata.append(("x-claims", json.dumps(claims)))
 
         self._stub = create_gateway_service_stub(self.channel)
 
diff --git a/airavata-python-sdk/airavata_sdk/clients/user_profile_client.py 
b/airavata-python-sdk/airavata_sdk/clients/user_profile_client.py
index 23f79cda77..0164eb5fbd 100644
--- a/airavata-python-sdk/airavata_sdk/clients/user_profile_client.py
+++ b/airavata-python-sdk/airavata_sdk/clients/user_profile_client.py
@@ -45,8 +45,6 @@ class UserProfileClient:
         self._metadata: list[tuple[str, str]] = []
         if access_token:
             self._metadata.append(("authorization", f"Bearer {access_token}"))
-        if claims:
-            self._metadata.append(("x-claims", json.dumps(claims)))
 
         self._stub = create_user_profile_service_stub(self.channel)
 
diff --git a/airavata-python-sdk/airavata_sdk/transport/utils.py 
b/airavata-python-sdk/airavata_sdk/transport/utils.py
index 56fe8b9afa..0b1fa2ab4c 100644
--- a/airavata-python-sdk/airavata_sdk/transport/utils.py
+++ b/airavata-python-sdk/airavata_sdk/transport/utils.py
@@ -51,8 +51,6 @@ class AuthMetadataPlugin(grpc.AuthMetadataPlugin):
 
     def __call__(self, context, callback):
         metadata = [("authorization", f"Bearer {self.access_token}")]
-        if self.claims:
-            metadata.append(("x-claims", json.dumps(self.claims)))
         callback(metadata, None)
 
 
@@ -61,8 +59,6 @@ def build_metadata(access_token: Optional[str] = None, 
claims: Optional[dict] =
     metadata = []
     if access_token:
         metadata.append(("authorization", f"Bearer {access_token}"))
-    if claims:
-        metadata.append(("x-claims", json.dumps(claims)))
     return metadata
 
 
diff --git 
a/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/AuthTokenExtractor.java
 
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/AuthTokenExtractor.java
index 811ff78459..68aea0c938 100644
--- 
a/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/AuthTokenExtractor.java
+++ 
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/AuthTokenExtractor.java
@@ -19,25 +19,18 @@
 */
 package org.apache.airavata.server.grpc.config;
 
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
 import java.util.HashMap;
 import java.util.Map;
 import org.apache.airavata.config.Constants;
 import org.apache.airavata.model.security.proto.AuthzToken;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /**
- * Shared parsing of the Bearer access token and the {@code x-claims} header 
used by both the gRPC
- * ({@link GrpcAuthInterceptor}) and HTTP ({@link HttpAuthDecorator}) 
authentication paths. Transport-specific
- * concerns (rejecting missing tokens, per-transport header fallbacks, 
UserContext lifecycle) stay in the callers.
+ * Shared Bearer-token handling for the gRPC ({@link GrpcAuthInterceptor}) and 
HTTP ({@link HttpAuthDecorator})
+ * authentication paths. Identity and roles come solely from the 
signature-verified token ({@link VerifiedToken});
+ * client-asserted headers (e.g. {@code x-claims}) are never consulted.
  */
 public final class AuthTokenExtractor {
 
-    private static final Logger log = 
LoggerFactory.getLogger(AuthTokenExtractor.class);
-    private static final ObjectMapper objectMapper = new ObjectMapper();
-
     private AuthTokenExtractor() {}
 
     /** Returns the bearer access token, or {@code null} if the header is 
absent or not a Bearer token. */
@@ -48,28 +41,22 @@ public final class AuthTokenExtractor {
         return null;
     }
 
-    /** Parses the {@code x-claims} JSON header into a mutable claims map; 
empty on absence or parse failure. */
-    public static Map<String, String> parseClaims(String claimsHeader) {
-        if (claimsHeader != null && !claimsHeader.isBlank()) {
-            try {
-                return objectMapper.readValue(claimsHeader, new 
TypeReference<Map<String, String>>() {});
-            } catch (Exception e) {
-                log.warn("Failed to parse x-claims: {}", e.getMessage());
-            }
-        }
-        return new HashMap<>();
-    }
-
     /**
-     * Builds an AuthzToken from the access token and (possibly augmented) 
claims map. The caller's realm roles
-     * are derived from the verified access token (not the client-asserted 
{@code x-claims}) and written into the
-     * claims map under {@link Constants#REALM_ROLES} as a CSV, overwriting 
any client-supplied value.
+     * Builds an {@link AuthzToken} whose claims map (user, gateway, realm 
roles) is populated solely from the
+     * verified token. Downstream {@code 
UserContext.userId()/gatewayId()/roles()} read these verified values.
      */
-    public static AuthzToken buildAuthzToken(String accessToken, Map<String, 
String> claimsMap) {
-        claimsMap.put(Constants.REALM_ROLES, String.join(",", 
JwtVerifier.verifyAndExtractRoles(accessToken)));
+    public static AuthzToken buildAuthzToken(String accessToken, VerifiedToken 
verified) {
+        Map<String, String> claims = new HashMap<>();
+        if (verified.userName() != null) {
+            claims.put(Constants.USER_NAME, verified.userName());
+        }
+        if (verified.gatewayId() != null) {
+            claims.put(Constants.GATEWAY_ID, verified.gatewayId());
+        }
+        claims.put(Constants.REALM_ROLES, String.join(",", verified.roles()));
         return AuthzToken.newBuilder()
                 .setAccessToken(accessToken)
-                .putAllClaimsMap(claimsMap)
+                .putAllClaimsMap(claims)
                 .build();
     }
 }
diff --git 
a/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/GrpcAuthInterceptor.java
 
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/GrpcAuthInterceptor.java
index c7a716e957..1046c0e0b8 100644
--- 
a/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/GrpcAuthInterceptor.java
+++ 
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/GrpcAuthInterceptor.java
@@ -25,7 +25,6 @@ import io.grpc.ServerCall;
 import io.grpc.ServerCallHandler;
 import io.grpc.ServerInterceptor;
 import io.grpc.Status;
-import java.util.Map;
 import org.apache.airavata.config.UserContext;
 import org.apache.airavata.model.security.proto.AuthzToken;
 import org.slf4j.Logger;
@@ -39,8 +38,6 @@ public class GrpcAuthInterceptor implements ServerInterceptor 
{
 
     private static final Metadata.Key<String> AUTHORIZATION_KEY =
             Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
-    private static final Metadata.Key<String> X_CLAIMS_KEY =
-            Metadata.Key.of("x-claims", Metadata.ASCII_STRING_MARSHALLER);
 
     @Override
     public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
@@ -52,8 +49,15 @@ public class GrpcAuthInterceptor implements 
ServerInterceptor {
             return new ServerCall.Listener<>() {};
         }
 
-        Map<String, String> claimsMap = 
AuthTokenExtractor.parseClaims(headers.get(X_CLAIMS_KEY));
-        AuthzToken authzToken = 
AuthTokenExtractor.buildAuthzToken(accessToken, claimsMap);
+        AuthzToken authzToken;
+        try {
+            VerifiedToken verified = JwtVerifier.verify(accessToken);
+            authzToken = AuthTokenExtractor.buildAuthzToken(accessToken, 
verified);
+        } catch (TokenVerificationException e) {
+            log.debug("Rejecting unauthenticated gRPC call: {}", 
e.getMessage());
+            call.close(Status.UNAUTHENTICATED.withDescription("Invalid access 
token"), new Metadata());
+            return new ServerCall.Listener<>() {};
+        }
         UserContext.setAuthzToken(authzToken);
 
         ServerCall.Listener<ReqT> delegate = next.startCall(call, headers);
diff --git 
a/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/HttpAuthDecorator.java
 
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/HttpAuthDecorator.java
index f59021d776..089d7fd027 100644
--- 
a/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/HttpAuthDecorator.java
+++ 
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/HttpAuthDecorator.java
@@ -25,8 +25,6 @@ import com.linecorp.armeria.common.HttpStatus;
 import com.linecorp.armeria.server.DecoratingHttpServiceFunction;
 import com.linecorp.armeria.server.HttpService;
 import com.linecorp.armeria.server.ServiceRequestContext;
-import java.util.Map;
-import org.apache.airavata.config.Constants;
 import org.apache.airavata.config.UserContext;
 import org.apache.airavata.model.security.proto.AuthzToken;
 import org.slf4j.Logger;
@@ -43,24 +41,14 @@ public class HttpAuthDecorator implements 
DecoratingHttpServiceFunction {
             return HttpResponse.of(HttpStatus.UNAUTHORIZED);
         }
 
-        Map<String, String> claimsMap =
-                AuthTokenExtractor.parseClaims(req.headers().get("x-claims"));
-
-        // Fall back to individual headers
-        if (!claimsMap.containsKey(Constants.USER_NAME)) {
-            String userName = req.headers().get("x-user-name");
-            if (userName != null) {
-                claimsMap.put(Constants.USER_NAME, userName);
-            }
-        }
-        if (!claimsMap.containsKey(Constants.GATEWAY_ID)) {
-            String gatewayId = req.headers().get("x-gateway-id");
-            if (gatewayId != null) {
-                claimsMap.put(Constants.GATEWAY_ID, gatewayId);
-            }
+        AuthzToken authzToken;
+        try {
+            VerifiedToken verified = JwtVerifier.verify(accessToken);
+            authzToken = AuthTokenExtractor.buildAuthzToken(accessToken, 
verified);
+        } catch (TokenVerificationException e) {
+            log.debug("Rejecting unauthenticated HTTP request: {}", 
e.getMessage());
+            return HttpResponse.of(HttpStatus.UNAUTHORIZED);
         }
-
-        AuthzToken authzToken = 
AuthTokenExtractor.buildAuthzToken(accessToken, claimsMap);
         UserContext.setAuthzToken(authzToken);
 
         try {
diff --git 
a/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/JwtVerifier.java
 
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/JwtVerifier.java
index 684f646db3..d17890a045 100644
--- 
a/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/JwtVerifier.java
+++ 
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/JwtVerifier.java
@@ -45,9 +45,9 @@ import org.slf4j.LoggerFactory;
  * extracts {@code realm_access.roles}. The issuer is taken from the token's 
own {@code iss} claim, and a JWKS
  * processor is cached per issuer so multi-tenant (multi-realm) tokens are 
each verified against their own realm.
  *
- * <p>Verification is fail-closed but non-rejecting: a missing, malformed, 
expired, or unverifiable token simply
- * yields no roles (logged), so the caller resolves to a non-admin. Roles are 
therefore only ever trusted when
- * they come from a signature-verified token — a forged token cannot assert 
admin.
+ * <p>Verification is fail-closed: any missing, malformed, expired, or 
unverifiable token throws
+ * {@link TokenVerificationException}, and the caller rejects the request. 
Identity (user + gateway) and roles
+ * are taken solely from the verified token; client-asserted headers are never 
trusted.
  */
 public final class JwtVerifier {
 
@@ -57,29 +57,56 @@ public final class JwtVerifier {
 
     private JwtVerifier() {}
 
-    /** Verifies the token and returns its realm roles, or an empty list if 
verification fails. */
-    public static List<String> verifyAndExtractRoles(String accessToken) {
+    /**
+     * Verifies the access token (RS256 signature against the realm JWKS, 
{@code exp}, and issuer) and returns
+     * the caller's identity and roles. Fail-closed: a missing, malformed, 
expired, or unverifiable token throws
+     * {@link TokenVerificationException}.
+     */
+    public static VerifiedToken verify(String accessToken) {
         if (accessToken == null || accessToken.isBlank()) {
-            return List.of();
+            throw new TokenVerificationException("Missing access token");
+        }
+        String issuer;
+        try {
+            issuer = unverifiedIssuer(accessToken);
+        } catch (Exception e) {
+            throw new TokenVerificationException("Malformed access token", e);
+        }
+        if (issuer == null) {
+            throw new TokenVerificationException("Access token has no issuer 
claim");
         }
         try {
-            String issuer = unverifiedIssuer(accessToken);
-            if (issuer == null) {
-                log.warn("Access token has no issuer claim; no realm roles 
extracted");
-                return List.of();
-            }
             JWTClaimsSet claims = processorFor(issuer).process(accessToken, 
null);
-            Map<String, Object> realmAccess = 
claims.getJSONObjectClaim("realm_access");
-            if (realmAccess == null || !(realmAccess.get("roles") instanceof 
List<?> roles)) {
-                return List.of();
-            }
-            List<String> result = 
roles.stream().map(String::valueOf).collect(Collectors.toList());
-            log.debug("Verified realm roles from {}: {}", issuer, result);
-            return result;
+            return toVerifiedToken(claims);
         } catch (Exception e) {
-            log.warn("JWT verification failed; no realm roles extracted: {}", 
e.getMessage());
-            return List.of();
+            throw new TokenVerificationException("Access token verification 
failed: " + e.getMessage(), e);
+        }
+    }
+
+    /** Pure mapping of a signature-verified claims set to identity + roles 
(no signature work). */
+    static VerifiedToken toVerifiedToken(JWTClaimsSet claims) throws Exception 
{
+        String userName = claims.getStringClaim("preferred_username");
+        String gatewayId = realmFromIssuer(claims.getIssuer());
+        List<String> roles = List.of();
+        Map<String, Object> realmAccess = 
claims.getJSONObjectClaim("realm_access");
+        if (realmAccess != null && realmAccess.get("roles") instanceof List<?> 
r) {
+            roles = 
r.stream().map(String::valueOf).collect(Collectors.toList());
+        }
+        return new VerifiedToken(userName, gatewayId, roles);
+    }
+
+    /** Parses the realm (gateway id) from a Keycloak issuer URL: {@code 
.../realms/<realm>} -> {@code <realm>}. */
+    static String realmFromIssuer(String issuer) {
+        if (issuer == null) {
+            return null;
+        }
+        int idx = issuer.indexOf("/realms/");
+        if (idx < 0) {
+            return null;
         }
+        String realm = issuer.substring(idx + "/realms/".length());
+        int slash = realm.indexOf('/');
+        return slash >= 0 ? realm.substring(0, slash) : realm;
     }
 
     /** Reads the {@code iss} claim from the token payload without verifying 
the signature. */
diff --git 
a/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/TokenVerificationException.java
 
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/TokenVerificationException.java
new file mode 100644
index 0000000000..fcce8d3758
--- /dev/null
+++ 
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/TokenVerificationException.java
@@ -0,0 +1,32 @@
+/**
+*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+package org.apache.airavata.server.grpc.config;
+
+/** Thrown when a Keycloak access token is missing, malformed, expired, or 
fails signature/issuer verification. */
+public class TokenVerificationException extends RuntimeException {
+
+    public TokenVerificationException(String message) {
+        super(message);
+    }
+
+    public TokenVerificationException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}
diff --git 
a/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/VerifiedToken.java
 
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/VerifiedToken.java
new file mode 100644
index 0000000000..ade9ee767c
--- /dev/null
+++ 
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/VerifiedToken.java
@@ -0,0 +1,29 @@
+/**
+*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+package org.apache.airavata.server.grpc.config;
+
+import java.util.List;
+
+/**
+ * Identity and authorization derived from a signature-verified Keycloak 
access token: the user
+ * ({@code preferred_username}), the gateway (the realm parsed from the token 
issuer), and the realm roles.
+ * This is the sole source of caller identity — client-asserted headers are 
never trusted.
+ */
+public record VerifiedToken(String userName, String gatewayId, List<String> 
roles) {}
diff --git 
a/airavata-server/src/test/java/org/apache/airavata/server/grpc/config/JwtVerifierTest.java
 
b/airavata-server/src/test/java/org/apache/airavata/server/grpc/config/JwtVerifierTest.java
new file mode 100644
index 0000000000..7c61fbed64
--- /dev/null
+++ 
b/airavata-server/src/test/java/org/apache/airavata/server/grpc/config/JwtVerifierTest.java
@@ -0,0 +1,74 @@
+/**
+*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+package org.apache.airavata.server.grpc.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for the pure identity-derivation logic of {@link JwtVerifier} — 
no signing or live Keycloak.
+ * The signature/expiry/issuer verification path is exercised end-to-end by 
the real-Keycloak integration check.
+ */
+class JwtVerifierTest {
+
+    @Test
+    void realmFromIssuer_parsesRealmAsGatewayId() {
+        assertEquals("default", 
JwtVerifier.realmFromIssuer("https://auth.airavata.host/realms/default";));
+        assertEquals("default", 
JwtVerifier.realmFromIssuer("https://auth.airavata.host/realms/default/";));
+        assertEquals("gw42", 
JwtVerifier.realmFromIssuer("https://kc.example.org/auth/realms/gw42";));
+        
assertNull(JwtVerifier.realmFromIssuer("https://auth.airavata.host/no-realm-here";));
+        assertNull(JwtVerifier.realmFromIssuer(null));
+    }
+
+    @Test
+    void toVerifiedToken_derivesUserGatewayAndRolesFromClaims() throws 
Exception {
+        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+                .issuer("https://auth.airavata.host/realms/default";)
+                .claim("preferred_username", "default-admin")
+                .claim("realm_access", Map.of("roles", List.of("admin-rw", 
"user")))
+                .build();
+
+        VerifiedToken v = JwtVerifier.toVerifiedToken(claims);
+
+        assertEquals("default-admin", v.userName());
+        assertEquals("default", v.gatewayId());
+        assertTrue(v.roles().contains("admin-rw"));
+        assertTrue(v.roles().contains("user"));
+    }
+
+    @Test
+    void toVerifiedToken_handlesMissingUsernameAndRoles() throws Exception {
+        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+                .issuer("https://auth.airavata.host/realms/default";)
+                .build();
+
+        VerifiedToken v = JwtVerifier.toVerifiedToken(claims);
+
+        assertNull(v.userName());
+        assertEquals("default", v.gatewayId());
+        assertTrue(v.roles().isEmpty());
+    }
+}
diff --git a/conf/keycloak/realm-default.json b/conf/keycloak/realm-default.json
index 202676f92b..a918e4d169 100644
--- a/conf/keycloak/realm-default.json
+++ b/conf/keycloak/realm-default.json
@@ -1619,6 +1619,120 @@
         "offline_access",
         "microprofile-jwt"
       ]
+    },
+    {
+      "clientId": "pga-public",
+      "name": "Airavata Django Portal (public SPA)",
+      "description": "Client for Airavata Portal",
+      "rootUrl": "",
+      "adminUrl": "",
+      "baseUrl": "",
+      "surrogateAuthRequired": false,
+      "enabled": true,
+      "alwaysDisplayInConsole": false,
+      "clientAuthenticatorType": "client-secret",
+      "redirectUris": [
+        "https://gateway.airavata.host/*";
+      ],
+      "webOrigins": [
+        "+"
+      ],
+      "notBefore": 0,
+      "bearerOnly": false,
+      "consentRequired": false,
+      "standardFlowEnabled": true,
+      "implicitFlowEnabled": false,
+      "directAccessGrantsEnabled": false,
+      "serviceAccountsEnabled": false,
+      "authorizationServicesEnabled": true,
+      "publicClient": true,
+      "frontchannelLogout": false,
+      "protocol": "openid-connect",
+      "attributes": {
+        "oidc.ciba.grant.enabled": "false",
+        "client.secret.creation.time": "1741724922",
+        "backchannel.logout.session.required": "true",
+        "post.logout.redirect.uris": "+",
+        "display.on.consent.screen": "false",
+        "oauth2.device.authorization.grant.enabled": "true",
+        "backchannel.logout.revoke.offline.tokens": "false",
+        "pkce.code.challenge.method": "S256"
+      },
+      "authenticationFlowBindingOverrides": {},
+      "fullScopeAllowed": true,
+      "nodeReRegistrationTimeout": -1,
+      "protocolMappers": [
+        {
+          "id": "f15a7de0-0c1e-40d8-bd05-c1aaf0deb3e1",
+          "name": "Client IP Address",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usersessionmodel-note-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.session.note": "clientAddress",
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "clientAddress",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "id": "a0956d0b-e5c4-4d9a-aebf-89efa6881438",
+          "name": "Client ID",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usersessionmodel-note-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.session.note": "client_id",
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "client_id",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "id": "6863299e-7d4f-43f4-8d0e-fc8cd4a8ceac",
+          "name": "Client Host",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usersessionmodel-note-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.session.note": "clientHost",
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "clientHost",
+            "jsonType.label": "String"
+          }
+        }
+      ],
+      "defaultClientScopes": [
+        "web-origins",
+        "acr",
+        "roles",
+        "profile",
+        "email"
+      ],
+      "optionalClientScopes": [
+        "address",
+        "phone",
+        "offline_access",
+        "preferred_username",
+        "microprofile-jwt"
+      ],
+      "authorizationSettings": {
+        "allowRemoteResourceManagement": true,
+        "policyEnforcementMode": "ENFORCING",
+        "resources": [],
+        "policies": [],
+        "scopes": [],
+        "decisionStrategy": "UNANIMOUS"
+      }
     }
   ],
   "clientScopes": [


Reply via email to