From 39bf3ddf39991bed62f5b331e0db097a2df9725d Mon Sep 17 00:00:00 2001 From: yasithdev Date: Fri, 19 Jun 2026 23:48:52 -0400 Subject: [PATCH 1/5] 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. --- .../grpc/config/AuthTokenExtractor.java | 43 ++++------- .../grpc/config/GrpcAuthInterceptor.java | 14 ++-- .../server/grpc/config/HttpAuthDecorator.java | 26 ++----- .../server/grpc/config/JwtVerifier.java | 67 ++++++++++++----- .../config/TokenVerificationException.java | 32 ++++++++ .../server/grpc/config/VerifiedToken.java | 29 ++++++++ .../server/grpc/config/JwtVerifierTest.java | 74 +++++++++++++++++++ 7 files changed, 213 insertions(+), 72 deletions(-) create mode 100644 airavata-server/src/main/java/org/apache/airavata/server/grpc/config/TokenVerificationException.java create mode 100644 airavata-server/src/main/java/org/apache/airavata/server/grpc/config/VerifiedToken.java create mode 100644 airavata-server/src/test/java/org/apache/airavata/server/grpc/config/JwtVerifierTest.java 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 static String stripBearer(String authHeader) { return null; } - /** Parses the {@code x-claims} JSON header into a mutable claims map; empty on absence or parse failure. */ - public static Map parseClaims(String claimsHeader) { - if (claimsHeader != null && !claimsHeader.isBlank()) { - try { - return objectMapper.readValue(claimsHeader, new TypeReference>() {}); - } 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 claimsMap) { - claimsMap.put(Constants.REALM_ROLES, String.join(",", JwtVerifier.verifyAndExtractRoles(accessToken))); + public static AuthzToken buildAuthzToken(String accessToken, VerifiedToken verified) { + Map 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.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 AUTHORIZATION_KEY = Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER); - private static final Metadata.Key X_CLAIMS_KEY = - Metadata.Key.of("x-claims", Metadata.ASCII_STRING_MARSHALLER); @Override public ServerCall.Listener interceptCall( @@ -52,8 +49,15 @@ public ServerCall.Listener interceptCall( return new ServerCall.Listener<>() {}; } - Map 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 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.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 HttpResponse serve(HttpService delegate, ServiceRequestContext ctx, HttpR return HttpResponse.of(HttpStatus.UNAUTHORIZED); } - Map 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 @@ * 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. * - *

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. + *

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 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 realmAccess = claims.getJSONObjectClaim("realm_access"); - if (realmAccess == null || !(realmAccess.get("roles") instanceof List roles)) { - return List.of(); - } - List 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 roles = List.of(); + Map 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/} -> {@code }. */ + 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 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()); + } +} From d151fce85d5e7168a9910c4a9f942fbcadc029b1 Mon Sep 17 00:00:00 2001 From: yasithdev Date: Fri, 19 Jun 2026 23:49:02 -0400 Subject: [PATCH 2/5] 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. --- airavata-python-sdk/airavata_sdk/client.py | 28 +++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) 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 @@ def __init__(self, host, port, token, gateway_id, secure=False, claims=None): 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 @@ def __init__(self, host, port, token, gateway_id, secure=False, claims=None): @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): From d6fa55c32b58925eeb45d2620215cd3755f77dec Mon Sep 17 00:00:00 2001 From: yasithdev Date: Fri, 19 Jun 2026 23:49:02 -0400 Subject: [PATCH 3/5] 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. --- Tiltfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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', From 0abda0a666b11fa9562ba466ee2b93f7523e310a Mon Sep 17 00:00:00 2001 From: yasithdev Date: Fri, 19 Jun 2026 23:53:56 -0400 Subject: [PATCH 4/5] 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. --- .../airavata_experiments/plan.py | 6 ----- .../airavata_jupyter_magic/__init__.py | 23 ------------------- .../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 -- .../clients/group_manager_client.py | 2 -- .../airavata_sdk/clients/iam_admin_client.py | 2 -- .../airavata_sdk/clients/plan_client.py | 2 -- .../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 -- .../clients/tenant_profile_client.py | 2 -- .../clients/user_profile_client.py | 2 -- .../airavata_sdk/transport/utils.py | 4 ---- 17 files changed, 61 deletions(-) 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 @@ def save(self) -> None: 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/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 @@ def __init__(self, access_token: Optional[str] = None, claims: Optional[dict] = 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 @@ def __init__(self, access_token: Optional[str] = None, claims: Optional[dict] = 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 @@ def __init__(self, access_token: Optional[str] = None, claims: Optional[dict] = 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 @@ def __init__(self, access_token: Optional[str] = None, claims: Optional[dict] = 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 @@ def __init__(self, access_token: Optional[str] = None, claims: Optional[dict] = 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 @@ def __init__(self, access_token: Optional[str] = None, claims: Optional[dict] = 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 @@ def __init__(self, access_token: Optional[str] = None, claims: Optional[dict] = 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 @@ def __init__(self, access_token: Optional[str] = None, claims: Optional[dict] = 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 @@ def __init__(self, access_token: Optional[str] = None, claims: Optional[dict] = 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 @@ def __init__(self, access_token: Optional[str] = None, claims: Optional[dict] = 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 @@ def __init__(self, access_token: Optional[str] = None, claims: Optional[dict] = 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 @@ def __init__(self, access_token: Optional[str] = None, claims: Optional[dict] = 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 @@ def __init__(self, access_token: Optional[str] = None, claims: Optional[dict] = 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 @@ def __init__(self, access_token: Optional[str] = None, claims: Optional[dict] = 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 @@ def __init__(self, access_token: str, claims: Optional[dict] = None): 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 From 81e563c892e01db60c83708633449d5ef2f7483a Mon Sep 17 00:00:00 2001 From: yasithdev Date: Sat, 20 Jun 2026 15:23:25 -0400 Subject: [PATCH 5/5] 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. --- conf/keycloak/realm-default.json | 114 +++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) 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": [