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 @@ 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/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): 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 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()); + } +} 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": [